diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45da8df7c..83ed8b177 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -306,8 +306,21 @@ jobs: - name: Build .NET solution run: dotnet build --no-restore --configuration Release + - name: Publish and run .NET Native AOT smoke test + run: | + dotnet publish tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj \ + --configuration Release \ + --runtime linux-x64 \ + --self-contained true \ + --output artifacts/aot-smoke + ./artifacts/aot-smoke/AgentHostProtocol.AotSmoke + - name: Test .NET solution - run: dotnet test --no-build --configuration Release + run: | + timeout --kill-after=30s 6m \ + dotnet tests/AgentHostProtocol.Tests/bin/Release/net8.0/AgentHostProtocol.Tests.dll \ + --timeout 5m \ + --long-running 30 - name: Pack .NET solution run: dotnet pack --no-build --configuration Release diff --git a/clients/dotnet/AgentHostProtocol.slnx b/clients/dotnet/AgentHostProtocol.slnx index 582f1210c..c9ea641c3 100644 --- a/clients/dotnet/AgentHostProtocol.slnx +++ b/clients/dotnet/AgentHostProtocol.slnx @@ -8,6 +8,7 @@ + diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index 29d8d2e33..c14ac6d4d 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -17,8 +17,8 @@ dotnet add package Microsoft.AgentHostProtocol | `Microsoft.AgentHostProtocol.Abstractions` | Wire types + reducers' data contracts + the `ITransport` / `IAhpSerializer` interfaces. No I/O, no dependencies. Reference this alone to parse / construct AHP messages or implement a transport. | | `Microsoft.AgentHostProtocol` | The async `AhpClient`, pure reducers, default System.Text.Json serializer, `ClientWebSocket` transport, and `MultiHostClient`. | -(`Microsoft.AgentHostProtocol` references `.Abstractions` transitively, so most -consumers add the two packages above.) +`Microsoft.AgentHostProtocol` references `.Abstractions` transitively, so most +consumers only add the main package. ## Quickstart @@ -63,17 +63,29 @@ services.AddAgentHostProtocol(cfg => cfg.DefaultRequestTimeout = TimeSpan.FromSe That registers `IAhpSerializer`, `IClientIdStore`, `MultiHostClient`, and an `IAhpClientFactory` as singletons. Because a client needs a live transport, -resolve the factory and call `ConnectAsync(transport)`: +resolve the factory and call `Connect(transport)`: ```csharp var factory = provider.GetRequiredService(); -await using var client = await factory.ConnectAsync(transport); +await using var client = factory.Connect(transport); ``` The `MultiHostClient` singleton is disposed by the container on shutdown. The `configureClient` options apply to the factory path; `MultiHostClient` hosts are configured per host via `HostConfig.ClientConfig`. +`ClientConfig.TimeProvider` controls request timeouts, keep-alive scheduling, +reconnect backoff, and host timestamps. It defaults to `TimeProvider.System`; +tests can supply a fake provider to advance these behaviors without wall-clock +delays. + +`MultiHostClient.EventsForHost` and `MultiHostClient.Subscriptions` preserve +resource continuity across reconnects. A replay is delivered as +`SubscriptionEventAction` values; when the server must replace replay with fresh +state, each returned resource is delivered as a `SubscriptionEventSnapshot` +before the host reports `Connected`. Consumers should replace that resource's +local reducer state with the snapshot before processing later actions. + ## Observability The client emits OpenTelemetry-native traces and metrics via `System.Diagnostics` @@ -119,6 +131,29 @@ implementation can swap the engine or decorate it with JSON-Schema validation (against the schemas the repository generates under `schema/`) without changing the client or transport. +### Native AOT and trimming + +The `net8.0` assets are trim- and Native AOT-compatible. The generator emits +System.Text.Json metadata for the complete generated protocol model. The +default serializer uses that metadata for JSON-RPC framing, discriminated +unions, snapshots, and wire enums without enabling reflection serialization. +`AhpJsonMetadata.Default` exposes the resolver without making the generated +context's hundreds of implementation-detail properties public API. + +Custom values passed through `IAhpSerializer` need their own metadata when +reflection is disabled. Add the application's context to the options before +constructing the serializer; the serializer copies and freezes the options and +adds the AHP context: + +```csharp +var options = new JsonSerializerOptions(); +options.TypeInfoResolverChain.Add(MyApplicationJsonContext.Default); +var serializer = new SystemTextJsonAhpSerializer(options); +``` + +CI publishes and runs `tests/AgentHostProtocol.AotSmoke` as a native executable +with `JsonSerializerIsReflectionEnabledByDefault=false`. + ## Releasing 1. Bump [`VERSION`](VERSION). diff --git a/clients/dotnet/docs/decisions/serialization.md b/clients/dotnet/docs/decisions/serialization.md index 8baa7414c..33eb2139d 100644 --- a/clients/dotnet/docs/decisions/serialization.md +++ b/clients/dotnet/docs/decisions/serialization.md @@ -43,8 +43,8 @@ the default is System.Text.Json and what the seam does and does not decouple. | Option | Throughput | Allocations | Eager/Lazy | Deps | AOT | Notes | | --- | --- | --- | --- | --- | --- | --- | -| **System.Text.Json (POCO)** ✅ | Highest | Lowest (`Span`/UTF-8) | Eager | **In-box** (net8) | Source-gen capable | Strict by default; Microsoft's greenfield recommendation. | -| System.Text.Json + source generation | Highest (+startup, +AOT) | Lowest | Eager | In-box | **Best** | An AOT/trimming enhancement for later — but **not** a drop-in `[JsonSerializable]` context: see "Deferred, on purpose" — the runtime-`Type`-keyed union converters do not compose with the source generator, so it requires reshaping the generated unions, not just adding a context. | +| **System.Text.Json + source generation** ✅ | Highest (+startup, +AOT) | Lowest | Eager | **In-box** (net8) | **Best** | Generated metadata covers the complete wire model; custom converters resolve metadata before runtime-type dispatch. | +| System.Text.Json (reflection contracts) | Highest | Lowest (`Span`/UTF-8) | Eager | In-box | Poor | Simpler initially, but unsuitable for trimming and Native AOT. | | Newtonsoft.Json (Json.NET) | ~20–35% slower; ~3× more allocations on .NET 10 | High (reflection, no `Span`) | Eager or `JObject` (lazy, mutable) | **+dependency** | Reflection (AOT-hostile) | Lenient by default; ubiquitous, but a dependency and slower. | | Lazy DOM — `JsonNode` / `JsonElement` (STJ) or `JObject` (Newtonsoft) | n/a (no bind) | Low for partial reads | **Lazy** | In-box (STJ) | ok | A *different consumption model*: expose untyped views instead of typed state. Reducers can't run on it without materializing. | | Utf8Json / Jil / other high-perf | Very high | Very low | Eager | +dependency | varies | Effectively unmaintained; not worth the dependency/risk for a JSON wire protocol. | @@ -72,14 +72,14 @@ Rationale, against the dimensions: - **Dependencies:** STJ is **in the shared framework** for net8 — the packages stay at **zero NuGet dependencies**, which is a hard goal for this library. -- **AOT / trimming:** STJ supports source generation as a path to - Native-AOT/trimming friendliness later. Note this is **not** a free, - drop-in step for this client: the discriminated unions dispatch on a - runtime `Type`, which the source generator does not support, so the - migration is a typed-variant rewrite of the generated unions (see - "Deferred, on purpose"). Until then the shipping libraries declare the - reflection unsafety via `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` - on the serializer seam so trimmed/AOT consumers are warned at build time. +- **AOT / trimming:** the C# generator emits metadata for every generated + protocol type and exposes it through the compact + `AhpJsonMetadata.Default` resolver API. Runtime-type union dispatch remains + compact, but resolves each known variant through + `JsonSerializerOptions.GetTypeInfo` and uses metadata-based serializer + overloads. The `net8.0` assets declare `IsTrimmable` and + `IsAotCompatible`; CI publishes and executes a reflection-disabled Native + AOT smoke application. - **Strictness:** strict-by-default is correct for a wire protocol — a malformed or unexpected frame should fail loudly, not be silently coerced. - **Custom shapes:** the protocol's discriminated unions, `StringOrMarkdown`, @@ -101,7 +101,7 @@ Rationale, against the dimensions: mean re-emitting the types for that engine — tractable since they're generated, but STJ stays the one default. -### Deferred, on purpose +### Boundaries and deferred work - **Validation ("validated vs not"):** a future opt-in `Microsoft.AgentHostProtocol.Validation` package will decorate @@ -112,29 +112,28 @@ Rationale, against the dimensions: without materializing typed state (a proxy/pass-through), that is a separate read-only `JsonNode`/`JsonElement` surface — not a drop-in serializer swap, because the reducers require typed state. -- **Source generation:** add source-gen for AOT/trimming and a further perf - bump when there is a concrete AOT consumer. This is **not** merely "add a - `[JsonSerializable]` `JsonSerializerContext`." The union machinery is - fundamentally reflection-polymorphic: `UnionConverter.Read` resolves the - payload type at runtime from a `Dictionary` and calls - `root.Deserialize(variantType, options)`, and `Write` serializes via - `inner.GetType()`. The STJ source generator only emits metadata for - compile-time-known closed types and does **not** support custom converters - that dispatch on a runtime `Type`. A real source-gen migration therefore - requires reshaping every discriminated union away from the `object?`-valued - `AhpUnion` + runtime-`Type` dispatch toward a closed, statically-known variant - representation (e.g. STJ's `[JsonPolymorphic]`/`[JsonDerivedType]`, or - per-variant typed properties) — a redesign of the generated wire types plus - the codegen, not a drop-in. In the meantime the libraries opt into the - trim/AOT analyzers and annotate the reflection entry points with - `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` so the limitation is - declared at build time rather than discovered at runtime. +- **Application-defined JSON types:** the AHP context intentionally contains + only protocol types. Consumers that pass their own types through + `IAhpSerializer` must add an `IJsonTypeInfoResolver` (normally their own + source-generated context) to the options supplied to + `SystemTextJsonAhpSerializer` when reflection serialization is disabled. + Reflection-enabled applications retain the generic serializer methods' + compatibility fallback for application-defined values. +- **Generated union shape:** `AhpUnion.Value` still provides a compact common + representation and supports lossless unknown-value round trips. The + converter's runtime `Type` lookup is AOT-safe because every known variant has + generated metadata, but the wrapper can still represent an empty or + caller-supplied invalid state. Hardening that public shape would be a separate + compatibility decision. ## Consequences - The default path is fast, allocation-light, and dependency-free. +- Native AOT and trimmed applications use the default serializer without + reflection contracts. - A different engine or a validating layer can be added behind `IAhpSerializer` without touching the client or transport. +- Custom application types require explicitly registered JSON metadata. - Consumers who want JSON-Schema validation opt into a separate package; the core never pays for it. diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj b/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj index 27531a4d3..19dd159d7 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/AgentHostProtocol.Abstractions.csproj @@ -13,14 +13,10 @@ I/O and no dependencies beyond the base class library; reference it to parse, construct, or inspect AHP messages, or to implement a transport. - + + true + true true true diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs new file mode 100644 index 000000000..6dd6f13a8 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/JsonSerializerContext.generated.cs @@ -0,0 +1,460 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +namespace Microsoft.AgentHostProtocol; + +[JsonSerializable(typeof(ActionEnvelope))] +[JsonSerializable(typeof(ActionOrigin))] +[JsonSerializable(typeof(ActionType))] +[JsonSerializable(typeof(ActiveTurn))] +[JsonSerializable(typeof(AgentCapabilities))] +[JsonSerializable(typeof(AgentCustomization))] +[JsonSerializable(typeof(AgentInfo))] +[JsonSerializable(typeof(AgentSelection))] +[JsonSerializable(typeof(AhpMcpUiHostCapabilities))] +[JsonSerializable(typeof(Annotation))] +[JsonSerializable(typeof(AnnotationEntry))] +[JsonSerializable(typeof(AnnotationOrigin))] +[JsonSerializable(typeof(AnnotationsEntryRemovedAction))] +[JsonSerializable(typeof(AnnotationsEntrySetAction))] +[JsonSerializable(typeof(AnnotationsRemovedAction))] +[JsonSerializable(typeof(AnnotationsSetAction))] +[JsonSerializable(typeof(AnnotationsState))] +[JsonSerializable(typeof(AnnotationsSummary))] +[JsonSerializable(typeof(AnnotationsUpdatedAction))] +[JsonSerializable(typeof(AuthenticateParams))] +[JsonSerializable(typeof(AuthenticateResult))] +[JsonSerializable(typeof(AuthRequiredErrorData))] +[JsonSerializable(typeof(AuthRequiredParams))] +[JsonSerializable(typeof(AuthRequiredReason))] +[JsonSerializable(typeof(AutomationCancelledRunLifecycle))] +[JsonSerializable(typeof(AutomationCapabilities))] +[JsonSerializable(typeof(AutomationCatalogState))] +[JsonSerializable(typeof(AutomationCompletedRunLifecycle))] +[JsonSerializable(typeof(AutomationCreateCapability))] +[JsonSerializable(typeof(AutomationCreateRequestedAction))] +[JsonSerializable(typeof(AutomationDefinition))] +[JsonSerializable(typeof(AutomationDefinitionPatch))] +[JsonSerializable(typeof(AutomationEventTrigger))] +[JsonSerializable(typeof(AutomationFailedRunLifecycle))] +[JsonSerializable(typeof(AutomationManualRunOrigin))] +[JsonSerializable(typeof(AutomationMisfirePolicy))] +[JsonSerializable(typeof(AutomationOperation))] +[JsonSerializable(typeof(AutomationPendingRunLifecycle))] +[JsonSerializable(typeof(AutomationRemovedAction))] +[JsonSerializable(typeof(AutomationRunCancellationCapability))] +[JsonSerializable(typeof(AutomationRunCancelRequestedAction))] +[JsonSerializable(typeof(AutomationRunLifecycle))] +[JsonSerializable(typeof(AutomationRunLifecycleChangedAction))] +[JsonSerializable(typeof(AutomationRunningRunLifecycle))] +[JsonSerializable(typeof(AutomationRunOrigin))] +[JsonSerializable(typeof(AutomationRunOriginKind))] +[JsonSerializable(typeof(AutomationRunPrimarySessionChangedAction))] +[JsonSerializable(typeof(AutomationRunSessionRemovedAction))] +[JsonSerializable(typeof(AutomationRunSessionSetAction))] +[JsonSerializable(typeof(AutomationRunState))] +[JsonSerializable(typeof(AutomationRunStatus))] +[JsonSerializable(typeof(AutomationRunSummary))] +[JsonSerializable(typeof(AutomationSchedule))] +[JsonSerializable(typeof(AutomationScheduleCapabilities))] +[JsonSerializable(typeof(AutomationScheduleTrigger))] +[JsonSerializable(typeof(AutomationSessionOrigin))] +[JsonSerializable(typeof(AutomationSessionTemplate))] +[JsonSerializable(typeof(AutomationSetAction))] +[JsonSerializable(typeof(AutomationState))] +[JsonSerializable(typeof(AutomationTrigger))] +[JsonSerializable(typeof(AutomationTriggerDefinition))] +[JsonSerializable(typeof(AutomationTriggeredRunOrigin))] +[JsonSerializable(typeof(AutomationTriggerEventDefinition))] +[JsonSerializable(typeof(AutomationTriggerKind))] +[JsonSerializable(typeof(AutomationUpdateRequestedAction))] +[JsonSerializable(typeof(Changeset))] +[JsonSerializable(typeof(ChangesetCapabilities))] +[JsonSerializable(typeof(ChangesetClearedAction))] +[JsonSerializable(typeof(ChangesetContentChangedAction))] +[JsonSerializable(typeof(ChangesetFile))] +[JsonSerializable(typeof(ChangesetFileRemovedAction))] +[JsonSerializable(typeof(ChangesetFileSetAction))] +[JsonSerializable(typeof(ChangesetFilesReviewChangedAction))] +[JsonSerializable(typeof(ChangesetOperation))] +[JsonSerializable(typeof(ChangesetOperationFollowUp))] +[JsonSerializable(typeof(ChangesetOperationRangeTarget))] +[JsonSerializable(typeof(ChangesetOperationResourceTarget))] +[JsonSerializable(typeof(ChangesetOperationsChangedAction))] +[JsonSerializable(typeof(ChangesetOperationScope))] +[JsonSerializable(typeof(ChangesetOperationStatus))] +[JsonSerializable(typeof(ChangesetOperationStatusChangedAction))] +[JsonSerializable(typeof(ChangesetOperationTarget))] +[JsonSerializable(typeof(ChangesetState))] +[JsonSerializable(typeof(ChangesetStatus))] +[JsonSerializable(typeof(ChangesetStatusChangedAction))] +[JsonSerializable(typeof(ChangesSummary))] +[JsonSerializable(typeof(ChatActivityChangedAction))] +[JsonSerializable(typeof(ChatDeltaAction))] +[JsonSerializable(typeof(ChatDraftChangedAction))] +[JsonSerializable(typeof(ChatErrorAction))] +[JsonSerializable(typeof(ChatInputAnswer))] +[JsonSerializable(typeof(ChatInputAnswerChangedAction))] +[JsonSerializable(typeof(ChatInputAnswered))] +[JsonSerializable(typeof(ChatInputAnswerState))] +[JsonSerializable(typeof(ChatInputAnswerValue))] +[JsonSerializable(typeof(ChatInputAnswerValueKind))] +[JsonSerializable(typeof(ChatInputBooleanAnswerValue))] +[JsonSerializable(typeof(ChatInputBooleanQuestion))] +[JsonSerializable(typeof(ChatInputCompletedAction))] +[JsonSerializable(typeof(ChatInputMultiSelectQuestion))] +[JsonSerializable(typeof(ChatInputNumberAnswerValue))] +[JsonSerializable(typeof(ChatInputNumberQuestion))] +[JsonSerializable(typeof(ChatInputOption))] +[JsonSerializable(typeof(ChatInputQuestion))] +[JsonSerializable(typeof(ChatInputQuestionKind))] +[JsonSerializable(typeof(ChatInputRequest))] +[JsonSerializable(typeof(ChatInputRequestedAction))] +[JsonSerializable(typeof(ChatInputResponseKind))] +[JsonSerializable(typeof(ChatInputSelectedAnswerValue))] +[JsonSerializable(typeof(ChatInputSelectedManyAnswerValue))] +[JsonSerializable(typeof(ChatInputSingleSelectQuestion))] +[JsonSerializable(typeof(ChatInputSkipped))] +[JsonSerializable(typeof(ChatInputTextAnswerValue))] +[JsonSerializable(typeof(ChatInputTextQuestion))] +[JsonSerializable(typeof(ChatInteractivity))] +[JsonSerializable(typeof(ChatOrigin))] +[JsonSerializable(typeof(ChatOriginFork))] +[JsonSerializable(typeof(ChatOriginKind))] +[JsonSerializable(typeof(ChatOriginSideChat))] +[JsonSerializable(typeof(ChatOriginTool))] +[JsonSerializable(typeof(ChatOriginUser))] +[JsonSerializable(typeof(ChatPendingMessageRemovedAction))] +[JsonSerializable(typeof(ChatPendingMessageSetAction))] +[JsonSerializable(typeof(ChatQueuedMessagesReorderedAction))] +[JsonSerializable(typeof(ChatReasoningAction))] +[JsonSerializable(typeof(ChatResponsePartAction))] +[JsonSerializable(typeof(ChatSource))] +[JsonSerializable(typeof(ChatSourceKind))] +[JsonSerializable(typeof(ChatState))] +[JsonSerializable(typeof(ChatSummary))] +[JsonSerializable(typeof(ChatToolCallAuthRequiredAction))] +[JsonSerializable(typeof(ChatToolCallAuthResolvedAction))] +[JsonSerializable(typeof(ChatToolCallCompleteAction))] +[JsonSerializable(typeof(ChatToolCallConfirmedAction))] +[JsonSerializable(typeof(ChatToolCallContentChangedAction))] +[JsonSerializable(typeof(ChatToolCallDeltaAction))] +[JsonSerializable(typeof(ChatToolCallReadyAction))] +[JsonSerializable(typeof(ChatToolCallResultConfirmedAction))] +[JsonSerializable(typeof(ChatToolCallStartAction))] +[JsonSerializable(typeof(ChatTruncatedAction))] +[JsonSerializable(typeof(ChatTurnCancelledAction))] +[JsonSerializable(typeof(ChatTurnCompleteAction))] +[JsonSerializable(typeof(ChatTurnsLoadedAction))] +[JsonSerializable(typeof(ChatTurnStartedAction))] +[JsonSerializable(typeof(ChatUsageAction))] +[JsonSerializable(typeof(ChatWorkingDirectoryRemovedAction))] +[JsonSerializable(typeof(ChatWorkingDirectorySetAction))] +[JsonSerializable(typeof(ChildCustomization))] +[JsonSerializable(typeof(ClientCapabilities))] +[JsonSerializable(typeof(ClientPluginCustomization))] +[JsonSerializable(typeof(CompletionItem))] +[JsonSerializable(typeof(CompletionItemKind))] +[JsonSerializable(typeof(CompletionsParams))] +[JsonSerializable(typeof(CompletionsResult))] +[JsonSerializable(typeof(ConfigPropertySchema))] +[JsonSerializable(typeof(ConfigSchema))] +[JsonSerializable(typeof(ConfirmationOption))] +[JsonSerializable(typeof(ConfirmationOptionKind))] +[JsonSerializable(typeof(ContentEncoding))] +[JsonSerializable(typeof(ContentRef))] +[JsonSerializable(typeof(CreateChatParams))] +[JsonSerializable(typeof(CreateResourceWatchParams))] +[JsonSerializable(typeof(CreateResourceWatchResult))] +[JsonSerializable(typeof(CreateSessionParams))] +[JsonSerializable(typeof(CreateTerminalParams))] +[JsonSerializable(typeof(Customization))] +[JsonSerializable(typeof(CustomizationDegradedState))] +[JsonSerializable(typeof(CustomizationEnablement))] +[JsonSerializable(typeof(CustomizationEnablementGlobal))] +[JsonSerializable(typeof(CustomizationEnablementKind))] +[JsonSerializable(typeof(CustomizationEnablementSession))] +[JsonSerializable(typeof(CustomizationEnablementWorkspace))] +[JsonSerializable(typeof(CustomizationErrorState))] +[JsonSerializable(typeof(CustomizationLoadedState))] +[JsonSerializable(typeof(CustomizationLoadingState))] +[JsonSerializable(typeof(CustomizationLoadState))] +[JsonSerializable(typeof(CustomizationLoadStatus))] +[JsonSerializable(typeof(CustomizationType))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(DirectoryCustomization))] +[JsonSerializable(typeof(DirectoryEntry))] +[JsonSerializable(typeof(DispatchActionParams))] +[JsonSerializable(typeof(DisposeChatParams))] +[JsonSerializable(typeof(DisposeSessionParams))] +[JsonSerializable(typeof(DisposeTerminalParams))] +[JsonSerializable(typeof(ErrorInfo))] +[JsonSerializable(typeof(FetchAutomationRunsParams))] +[JsonSerializable(typeof(FetchAutomationRunsResult))] +[JsonSerializable(typeof(FetchTurnsParams))] +[JsonSerializable(typeof(FetchTurnsResult))] +[JsonSerializable(typeof(FileEdit))] +[JsonSerializable(typeof(ForkChatSource))] +[JsonSerializable(typeof(HookCustomization))] +[JsonSerializable(typeof(Icon))] +[JsonSerializable(typeof(Implementation))] +[JsonSerializable(typeof(InitializeParams))] +[JsonSerializable(typeof(InitializeResult))] +[JsonSerializable(typeof(InputRequestResponsePart))] +[JsonSerializable(typeof(InvokeChangesetOperationParams))] +[JsonSerializable(typeof(InvokeChangesetOperationResult))] +[JsonSerializable(typeof(JsonRpcErrorObject))] +[JsonSerializable(typeof(JsonRpcErrorResponse))] +[JsonSerializable(typeof(JsonRpcMessage))] +[JsonSerializable(typeof(JsonRpcNotification))] +[JsonSerializable(typeof(JsonRpcRequest))] +[JsonSerializable(typeof(JsonRpcSuccessResponse))] +[JsonSerializable(typeof(ListAutomationTriggerDefinitionsParams))] +[JsonSerializable(typeof(ListAutomationTriggerDefinitionsResult))] +[JsonSerializable(typeof(ListSessionsParams))] +[JsonSerializable(typeof(ListSessionsResult))] +[JsonSerializable(typeof(MarkdownResponsePart))] +[JsonSerializable(typeof(McpAuthRequiredReason))] +[JsonSerializable(typeof(McpAuthRequirement))] +[JsonSerializable(typeof(McpOAuthClient))] +[JsonSerializable(typeof(McpServerAuthRequiredState))] +[JsonSerializable(typeof(McpServerCustomization))] +[JsonSerializable(typeof(McpServerCustomizationApps))] +[JsonSerializable(typeof(McpServerErrorState))] +[JsonSerializable(typeof(McpServerReadyState))] +[JsonSerializable(typeof(McpServerStartingState))] +[JsonSerializable(typeof(McpServerState))] +[JsonSerializable(typeof(McpServerStatus))] +[JsonSerializable(typeof(McpServerStoppedState))] +[JsonSerializable(typeof(Message))] +[JsonSerializable(typeof(MessageAnnotationsAttachment))] +[JsonSerializable(typeof(MessageAttachment))] +[JsonSerializable(typeof(MessageAttachmentKind))] +[JsonSerializable(typeof(MessageChatAttachment))] +[JsonSerializable(typeof(MessageEmbeddedResourceAttachment))] +[JsonSerializable(typeof(MessageKind))] +[JsonSerializable(typeof(MessageOrigin))] +[JsonSerializable(typeof(MessageResourceAttachment))] +[JsonSerializable(typeof(ModelSelection))] +[JsonSerializable(typeof(MultipleChatsCapability))] +[JsonSerializable(typeof(MultipleWorkingDirectoriesCapability))] +[JsonSerializable(typeof(OtlpExportLogsParams))] +[JsonSerializable(typeof(OtlpExportMetricsParams))] +[JsonSerializable(typeof(OtlpExportTracesParams))] +[JsonSerializable(typeof(PartialChatSummary))] +[JsonSerializable(typeof(PartialSessionSummary))] +[JsonSerializable(typeof(PendingMessage))] +[JsonSerializable(typeof(PendingMessageKind))] +[JsonSerializable(typeof(PermissionDeniedErrorData))] +[JsonSerializable(typeof(PluginCustomization))] +[JsonSerializable(typeof(PolicyState))] +[JsonSerializable(typeof(ProgressParams))] +[JsonSerializable(typeof(ProjectInfo))] +[JsonSerializable(typeof(PromptCustomization))] +[JsonSerializable(typeof(ProtectedResourceMetadata))] +[JsonSerializable(typeof(ReasoningResponsePart))] +[JsonSerializable(typeof(ReconnectParams))] +[JsonSerializable(typeof(ReconnectReplayResult))] +[JsonSerializable(typeof(ReconnectResult))] +[JsonSerializable(typeof(ReconnectResultType))] +[JsonSerializable(typeof(ReconnectSnapshotResult))] +[JsonSerializable(typeof(ResolveSessionConfigParams))] +[JsonSerializable(typeof(ResolveSessionConfigResult))] +[JsonSerializable(typeof(ResourceChange))] +[JsonSerializable(typeof(ResourceChangeType))] +[JsonSerializable(typeof(ResourceCopyParams))] +[JsonSerializable(typeof(ResourceCopyResult))] +[JsonSerializable(typeof(ResourceDeleteParams))] +[JsonSerializable(typeof(ResourceDeleteResult))] +[JsonSerializable(typeof(ResourceListParams))] +[JsonSerializable(typeof(ResourceListResult))] +[JsonSerializable(typeof(ResourceMkdirParams))] +[JsonSerializable(typeof(ResourceMkdirResult))] +[JsonSerializable(typeof(ResourceMoveParams))] +[JsonSerializable(typeof(ResourceMoveResult))] +[JsonSerializable(typeof(ResourceReadParams))] +[JsonSerializable(typeof(ResourceReadResult))] +[JsonSerializable(typeof(ResourceRequestParams))] +[JsonSerializable(typeof(ResourceRequestResult))] +[JsonSerializable(typeof(ResourceResolveParams))] +[JsonSerializable(typeof(ResourceResolveResult))] +[JsonSerializable(typeof(ResourceResponsePart))] +[JsonSerializable(typeof(ResourceType))] +[JsonSerializable(typeof(ResourceWatchChangedAction))] +[JsonSerializable(typeof(ResourceWatchState))] +[JsonSerializable(typeof(ResourceWriteMode))] +[JsonSerializable(typeof(ResourceWriteParams))] +[JsonSerializable(typeof(ResourceWriteResult))] +[JsonSerializable(typeof(ResponsePart))] +[JsonSerializable(typeof(ResponsePartKind))] +[JsonSerializable(typeof(RootActiveSessionsChangedAction))] +[JsonSerializable(typeof(RootAgentsChangedAction))] +[JsonSerializable(typeof(RootConfigChangedAction))] +[JsonSerializable(typeof(RootConfigState))] +[JsonSerializable(typeof(RootState))] +[JsonSerializable(typeof(RootTerminalsChangedAction))] +[JsonSerializable(typeof(RuleCustomization))] +[JsonSerializable(typeof(RunAutomationParams))] +[JsonSerializable(typeof(RunAutomationResult))] +[JsonSerializable(typeof(SessionActiveClient))] +[JsonSerializable(typeof(SessionActiveClientRemovedAction))] +[JsonSerializable(typeof(SessionActiveClientSetAction))] +[JsonSerializable(typeof(SessionActivityChangedAction))] +[JsonSerializable(typeof(SessionAddedParams))] +[JsonSerializable(typeof(SessionChangesetsChangedAction))] +[JsonSerializable(typeof(SessionChatAddedAction))] +[JsonSerializable(typeof(SessionChatInputRequest))] +[JsonSerializable(typeof(SessionChatRemovedAction))] +[JsonSerializable(typeof(SessionChatUpdatedAction))] +[JsonSerializable(typeof(SessionConfigChangedAction))] +[JsonSerializable(typeof(SessionConfigCompletionsParams))] +[JsonSerializable(typeof(SessionConfigCompletionsResult))] +[JsonSerializable(typeof(SessionConfigPropertySchema))] +[JsonSerializable(typeof(SessionConfigSchema))] +[JsonSerializable(typeof(SessionConfigState))] +[JsonSerializable(typeof(SessionConfigValueItem))] +[JsonSerializable(typeof(SessionCreationFailedAction))] +[JsonSerializable(typeof(SessionCustomizationRemovedAction))] +[JsonSerializable(typeof(SessionCustomizationsChangedAction))] +[JsonSerializable(typeof(SessionCustomizationToggledAction))] +[JsonSerializable(typeof(SessionCustomizationUpdatedAction))] +[JsonSerializable(typeof(SessionDefaultChatChangedAction))] +[JsonSerializable(typeof(SessionDeltaAction))] +[JsonSerializable(typeof(SessionErrorAction))] +[JsonSerializable(typeof(SessionInputNeededRemovedAction))] +[JsonSerializable(typeof(SessionInputNeededSetAction))] +[JsonSerializable(typeof(SessionInputRequest))] +[JsonSerializable(typeof(SessionInputRequestKind))] +[JsonSerializable(typeof(SessionIsArchivedChangedAction))] +[JsonSerializable(typeof(SessionIsReadChangedAction))] +[JsonSerializable(typeof(SessionLifecycle))] +[JsonSerializable(typeof(SessionMcpServerStartRequestedAction))] +[JsonSerializable(typeof(SessionMcpServerStateChangedAction))] +[JsonSerializable(typeof(SessionMcpServerStopRequestedAction))] +[JsonSerializable(typeof(SessionMetaChangedAction))] +[JsonSerializable(typeof(SessionModelInfo))] +[JsonSerializable(typeof(SessionOrigin))] +[JsonSerializable(typeof(SessionOriginKind))] +[JsonSerializable(typeof(SessionPendingMessageRemovedAction))] +[JsonSerializable(typeof(SessionPendingMessageSetAction))] +[JsonSerializable(typeof(SessionQueuedMessagesReorderedAction))] +[JsonSerializable(typeof(SessionReadyAction))] +[JsonSerializable(typeof(SessionReasoningAction))] +[JsonSerializable(typeof(SessionRemovedParams))] +[JsonSerializable(typeof(SessionResponsePartAction))] +[JsonSerializable(typeof(SessionServerToolsChangedAction))] +[JsonSerializable(typeof(SessionState))] +[JsonSerializable(typeof(SessionStatus))] +[JsonSerializable(typeof(SessionSummary))] +[JsonSerializable(typeof(SessionSummaryChangedParams))] +[JsonSerializable(typeof(SessionTitleChangedAction))] +[JsonSerializable(typeof(SessionToolAuthenticationRequest))] +[JsonSerializable(typeof(SessionToolCallCompleteAction))] +[JsonSerializable(typeof(SessionToolCallConfirmedAction))] +[JsonSerializable(typeof(SessionToolCallContentChangedAction))] +[JsonSerializable(typeof(SessionToolCallDeltaAction))] +[JsonSerializable(typeof(SessionToolCallReadyAction))] +[JsonSerializable(typeof(SessionToolCallResultConfirmedAction))] +[JsonSerializable(typeof(SessionToolCallStartAction))] +[JsonSerializable(typeof(SessionToolClientExecutionRequest))] +[JsonSerializable(typeof(SessionToolConfirmationRequest))] +[JsonSerializable(typeof(SessionTruncatedAction))] +[JsonSerializable(typeof(SessionTurnCancelledAction))] +[JsonSerializable(typeof(SessionTurnCompleteAction))] +[JsonSerializable(typeof(SessionTurnStartedAction))] +[JsonSerializable(typeof(SessionUsageAction))] +[JsonSerializable(typeof(SessionWorkingDirectoryRemovedAction))] +[JsonSerializable(typeof(SessionWorkingDirectoryReplacedAction))] +[JsonSerializable(typeof(SessionWorkingDirectorySetAction))] +[JsonSerializable(typeof(SideChatSelection))] +[JsonSerializable(typeof(SideChatSource))] +[JsonSerializable(typeof(SimpleMessageAttachment))] +[JsonSerializable(typeof(SkillCustomization))] +[JsonSerializable(typeof(Snapshot))] +[JsonSerializable(typeof(SnapshotState))] +[JsonSerializable(typeof(StateAction))] +[JsonSerializable(typeof(StringOrMarkdown))] +[JsonSerializable(typeof(SubscribeParams))] +[JsonSerializable(typeof(SubscribeResult))] +[JsonSerializable(typeof(SubscribeView))] +[JsonSerializable(typeof(SubscriptionDeliveryOptions))] +[JsonSerializable(typeof(SystemNotificationResponsePart))] +[JsonSerializable(typeof(TelemetryCapabilities))] +[JsonSerializable(typeof(TerminalClaim))] +[JsonSerializable(typeof(TerminalClaimedAction))] +[JsonSerializable(typeof(TerminalClaimKind))] +[JsonSerializable(typeof(TerminalClearedAction))] +[JsonSerializable(typeof(TerminalClientClaim))] +[JsonSerializable(typeof(TerminalCommandDetectionAvailableAction))] +[JsonSerializable(typeof(TerminalCommandExecutedAction))] +[JsonSerializable(typeof(TerminalCommandFinishedAction))] +[JsonSerializable(typeof(TerminalCommandPart))] +[JsonSerializable(typeof(TerminalCommandResult))] +[JsonSerializable(typeof(TerminalContentPart))] +[JsonSerializable(typeof(TerminalCwdChangedAction))] +[JsonSerializable(typeof(TerminalDataAction))] +[JsonSerializable(typeof(TerminalExitedAction))] +[JsonSerializable(typeof(TerminalExitedLifecycleState))] +[JsonSerializable(typeof(TerminalInfo))] +[JsonSerializable(typeof(TerminalInputAction))] +[JsonSerializable(typeof(TerminalLifecycleState))] +[JsonSerializable(typeof(TerminalLifecycleStatus))] +[JsonSerializable(typeof(TerminalResizedAction))] +[JsonSerializable(typeof(TerminalRunningLifecycleState))] +[JsonSerializable(typeof(TerminalSessionClaim))] +[JsonSerializable(typeof(TerminalState))] +[JsonSerializable(typeof(TerminalTitleChangedAction))] +[JsonSerializable(typeof(TerminalUnclassifiedPart))] +[JsonSerializable(typeof(TextPosition))] +[JsonSerializable(typeof(TextRange))] +[JsonSerializable(typeof(TextSelection))] +[JsonSerializable(typeof(ToolAnnotations))] +[JsonSerializable(typeof(ToolCallAuthRequiredState))] +[JsonSerializable(typeof(ToolCallCancellationReason))] +[JsonSerializable(typeof(ToolCallCancelledState))] +[JsonSerializable(typeof(ToolCallClientContributor))] +[JsonSerializable(typeof(ToolCallCompletedState))] +[JsonSerializable(typeof(ToolCallConfirmationReason))] +[JsonSerializable(typeof(ToolCallConfirmationState))] +[JsonSerializable(typeof(ToolCallContributor))] +[JsonSerializable(typeof(ToolCallContributorKind))] +[JsonSerializable(typeof(ToolCallMcpContributor))] +[JsonSerializable(typeof(ToolCallPendingConfirmationState))] +[JsonSerializable(typeof(ToolCallPendingResultConfirmationState))] +[JsonSerializable(typeof(ToolCallResponsePart))] +[JsonSerializable(typeof(ToolCallResult))] +[JsonSerializable(typeof(ToolCallRiskAssessment))] +[JsonSerializable(typeof(ToolCallRiskAssessmentCompleteState))] +[JsonSerializable(typeof(ToolCallRiskAssessmentKind))] +[JsonSerializable(typeof(ToolCallRiskAssessmentLoadingState))] +[JsonSerializable(typeof(ToolCallRiskAssessmentStatus))] +[JsonSerializable(typeof(ToolCallRunningState))] +[JsonSerializable(typeof(ToolCallState))] +[JsonSerializable(typeof(ToolCallStatus))] +[JsonSerializable(typeof(ToolCallStreamingState))] +[JsonSerializable(typeof(ToolDefinition))] +[JsonSerializable(typeof(ToolInput))] +[JsonSerializable(typeof(ToolResultContent))] +[JsonSerializable(typeof(ToolResultContentType))] +[JsonSerializable(typeof(ToolResultEmbeddedResourceContent))] +[JsonSerializable(typeof(ToolResultFileEditContent))] +[JsonSerializable(typeof(ToolResultResourceContent))] +[JsonSerializable(typeof(ToolResultSubagentContent))] +[JsonSerializable(typeof(ToolResultTerminalContent))] +[JsonSerializable(typeof(ToolResultTextContent))] +[JsonSerializable(typeof(Turn))] +[JsonSerializable(typeof(TurnState))] +[JsonSerializable(typeof(UnsubscribeParams))] +[JsonSerializable(typeof(UnsupportedProtocolVersionErrorData))] +[JsonSerializable(typeof(UsageInfo))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + GenerationMode = JsonSourceGenerationMode.Metadata)] +internal partial class AgentHostProtocolJsonContext : JsonSerializerContext +{ +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs index d00905ef7..39af8b151 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Messages.generated.cs @@ -103,19 +103,19 @@ public override JsonRpcMessage Read(ref Utf8JsonReader reader, Type typeToConver var msg = new JsonRpcMessage(); if (hasMethod && hasId) { - msg.Request = root.Deserialize(options); + msg.Request = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (hasMethod) { - msg.Notification = root.Deserialize(options); + msg.Notification = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (hasError) { - msg.ErrorResponse = root.Deserialize(options); + msg.ErrorResponse = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (hasResult) { - msg.SuccessResponse = root.Deserialize(options); + msg.SuccessResponse = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else { @@ -126,10 +126,10 @@ public override JsonRpcMessage Read(ref Utf8JsonReader reader, Type typeToConver public override void Write(Utf8JsonWriter writer, JsonRpcMessage value, JsonSerializerOptions options) { - if (value.Request is not null) { JsonSerializer.Serialize(writer, value.Request, options); return; } - if (value.SuccessResponse is not null) { JsonSerializer.Serialize(writer, value.SuccessResponse, options); return; } - if (value.ErrorResponse is not null) { JsonSerializer.Serialize(writer, value.ErrorResponse, options); return; } - if (value.Notification is not null) { JsonSerializer.Serialize(writer, value.Notification, options); return; } + if (value.Request is not null) { JsonSerializer.Serialize(writer, value.Request, AhpJsonTypeInfo.Get(options)); return; } + if (value.SuccessResponse is not null) { JsonSerializer.Serialize(writer, value.SuccessResponse, AhpJsonTypeInfo.Get(options)); return; } + if (value.ErrorResponse is not null) { JsonSerializer.Serialize(writer, value.ErrorResponse, AhpJsonTypeInfo.Get(options)); return; } + if (value.Notification is not null) { JsonSerializer.Serialize(writer, value.Notification, AhpJsonTypeInfo.Get(options)); return; } writer.WriteNullValue(); } } diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs index b9173a034..532cdb4f0 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/State.generated.cs @@ -6210,13 +6210,22 @@ public override ToolInput Read(ref Utf8JsonReader reader, Type typeToConvert, Js { return new ToolInput { Inline = reader.GetString() }; } - return new ToolInput { ContentRef = JsonSerializer.Deserialize(ref reader, options) }; + return new ToolInput + { + ContentRef = JsonSerializer.Deserialize( + ref reader, + AhpJsonTypeInfo.Get(options)) + }; } public override void Write(Utf8JsonWriter writer, ToolInput value, JsonSerializerOptions options) { if (value.Inline is not null) { writer.WriteStringValue(value.Inline); return; } - if (value.ContentRef is not null) { JsonSerializer.Serialize(writer, value.ContentRef, options); return; } + if (value.ContentRef is not null) + { + JsonSerializer.Serialize(writer, value.ContentRef, AhpJsonTypeInfo.Get(options)); + return; + } writer.WriteNullValue(); } } @@ -6271,57 +6280,57 @@ public override SnapshotState Read(ref Utf8JsonReader reader, Type typeToConvert root.TryGetProperty("origin", out _) && root.TryGetProperty("sessions", out _)) { - result.AutomationRun = root.Deserialize(options); + result.AutomationRun = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("automations", out _)) { - result.Automations = root.Deserialize(options); + result.Automations = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("turns", out _)) { - result.Chat = root.Deserialize(options); + result.Chat = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("lifecycle", out _)) { // SessionState is discriminated on its required `lifecycle` field. // (It no longer carries `summary`; that field was removed when the // session state was flattened.) - result.Session = root.Deserialize(options); + result.Session = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("content", out _)) { - result.Terminal = root.Deserialize(options); + result.Terminal = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("status", out _) && root.TryGetProperty("files", out _)) { - result.Changeset = root.Deserialize(options); + result.Changeset = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("root", out _) && root.TryGetProperty("recursive", out _)) { - result.ResourceWatch = root.Deserialize(options); + result.ResourceWatch = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("annotations", out _)) { - result.Annotations = root.Deserialize(options); + result.Annotations = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else { - result.Root = root.Deserialize(options); + result.Root = root.Deserialize(AhpJsonTypeInfo.Get(options)); } return result; } public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) { - if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, options); return; } - if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, options); return; } - if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, options); return; } - if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, options); return; } - if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, options); return; } - if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, options); return; } - if (value.ResourceWatch is not null) { JsonSerializer.Serialize(writer, value.ResourceWatch, options); return; } - if (value.Annotations is not null) { JsonSerializer.Serialize(writer, value.Annotations, options); return; } - if (value.Root is not null) { JsonSerializer.Serialize(writer, value.Root, options); return; } + if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } + if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } + if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } + if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, AhpJsonTypeInfo.Get(options)); return; } + if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, AhpJsonTypeInfo.Get(options)); return; } + if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, AhpJsonTypeInfo.Get(options)); return; } + if (value.ResourceWatch is not null) { JsonSerializer.Serialize(writer, value.ResourceWatch, AhpJsonTypeInfo.Get(options)); return; } + if (value.Annotations is not null) { JsonSerializer.Serialize(writer, value.Annotations, AhpJsonTypeInfo.Get(options)); return; } + if (value.Root is not null) { JsonSerializer.Serialize(writer, value.Root, AhpJsonTypeInfo.Get(options)); return; } writer.WriteNullValue(); } } diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpJsonMetadata.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpJsonMetadata.cs new file mode 100644 index 000000000..aada60eae --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpJsonMetadata.cs @@ -0,0 +1,12 @@ +#nullable enable + +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.AgentHostProtocol; + +/// Provides source-generated System.Text.Json metadata for the complete AHP wire model. +public static class AhpJsonMetadata +{ + /// Gets the source-generated resolver for AHP protocol types. + public static IJsonTypeInfoResolver Default => AgentHostProtocolJsonContext.Default; +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpJsonTypeInfo.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpJsonTypeInfo.cs new file mode 100644 index 000000000..81d0457d9 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/AhpJsonTypeInfo.cs @@ -0,0 +1,13 @@ +#nullable enable + +using System.Text.Json.Serialization.Metadata; + +namespace Microsoft.AgentHostProtocol; + +internal static class AhpJsonTypeInfo +{ + public static JsonTypeInfo Get(JsonSerializerOptions options) => + options.GetTypeInfo(typeof(T)) as JsonTypeInfo + ?? throw new NotSupportedException( + $"No JSON metadata is registered for {typeof(T)}."); +} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs index 806c976f6..d1a91e262 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/IAhpSerializer.cs @@ -4,7 +4,6 @@ #nullable enable using System; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; namespace Microsoft.AgentHostProtocol; @@ -20,8 +19,6 @@ namespace Microsoft.AgentHostProtocol; public interface IAhpSerializer { /// Serializes to a JSON string. - [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] - [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] string Serialize(T value); /// @@ -30,18 +27,12 @@ public interface IAhpSerializer /// undisposed-document leak that JsonDocument.Parse(Serialize(x)).RootElement /// incurs). The returned element owns its backing memory and is safe to retain. /// - [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] - [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] JsonElement SerializeToElement(T value); /// Deserializes a JSON string into . - [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] - [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] T Deserialize(string json); /// Deserializes UTF-8 JSON bytes into . - [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] - [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] T Deserialize(ReadOnlySpan utf8Json); /// @@ -52,36 +43,14 @@ public interface IAhpSerializer /// Deserialize<T>(element.GetRawText()) on hot paths (inbound /// notifications, request results) where the element is already in hand. /// - [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] - [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] T Deserialize(JsonElement element); /// /// Decodes a transport frame into a , picking the /// correct variant (request / notification / success / error) from its shape. /// - [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] - [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] JsonRpcMessage DecodeMessage(TransportMessage message); /// Encodes a into a text transport frame. - [RequiresUnreferencedCode(SerializerTrimWarnings.UnreferencedCode)] - [RequiresDynamicCode(SerializerTrimWarnings.DynamicCode)] TransportMessage EncodeMessage(JsonRpcMessage message); } - -/// -/// Shared / -/// messages for the serializer seam. -/// The default SystemTextJsonAhpSerializer is reflection-based (source-gen -/// is deferred per docs/decisions/serialization.md), so every -/// (de)serialization entry point declares the trim/AOT unsafety on the contract. -/// -internal static class SerializerTrimWarnings -{ - public const string UnreferencedCode = - "JSON (de)serialization here is reflection-based and may reference types that cannot be statically analyzed when trimming. Provide a JsonSerializerContext or preserve the wire types."; - - public const string DynamicCode = - "JSON (de)serialization here is reflection-based and may require runtime code generation under Native AOT. Use System.Text.Json source generation for AOT."; -} diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs index c596c6e97..ddb5bb68d 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/StringOrMarkdown.cs @@ -20,21 +20,29 @@ public sealed class StringOrMarkdown /// Non-null iff the value was decoded from the { "markdown": "..." } /// object form. /// - public string? Markdown { get; init; } + public string? Markdown { get; } /// Non-null iff the value was decoded from a bare JSON string. - public string? Plain { get; init; } + public string? Plain { get; } /// Creates an empty value (encodes as ""). public StringOrMarkdown() { } + private StringOrMarkdown(string? plain, string? markdown) + { + Plain = plain; + Markdown = markdown; + } + /// Returns a value that encodes as a bare JSON string. - public static StringOrMarkdown FromPlain(string text) => new() { Plain = text }; + public static StringOrMarkdown FromPlain(string text) => + new(text ?? throw new ArgumentNullException(nameof(text)), markdown: null); /// Returns a value that encodes as { "markdown": text }. - public static StringOrMarkdown FromMarkdown(string text) => new() { Markdown = text }; + public static StringOrMarkdown FromMarkdown(string text) => + new(plain: null, text ?? throw new ArgumentNullException(nameof(text))); /// /// Returns the underlying text regardless of which form the value was @@ -54,7 +62,7 @@ public override StringOrMarkdown Read(ref Utf8JsonReader reader, Type typeToConv case JsonTokenType.Null: return new StringOrMarkdown(); case JsonTokenType.String: - return new StringOrMarkdown { Plain = reader.GetString() }; + return StringOrMarkdown.FromPlain(reader.GetString()!); default: using (JsonDocument doc = JsonDocument.ParseValue(ref reader)) { @@ -62,7 +70,7 @@ public override StringOrMarkdown Read(ref Utf8JsonReader reader, Type typeToConv && doc.RootElement.TryGetProperty("markdown", out JsonElement md) && md.ValueKind == JsonValueKind.String) { - return new StringOrMarkdown { Markdown = md.GetString() }; + return StringOrMarkdown.FromMarkdown(md.GetString()!); } throw new JsonException("StringOrMarkdown object form missing required 'markdown' field"); diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs index 588fc8f9b..f150688ee 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Json/UnionConverter.cs @@ -4,9 +4,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace Microsoft.AgentHostProtocol; @@ -42,17 +42,6 @@ protected UnionConverter( } /// - // root.Deserialize(variantType, options) resolves the payload type at runtime - // from the variant map — genuinely trim/AOT-unsafe. The unsafety is already - // declared on the public contract (IAhpSerializer is [RequiresUnreferencedCode]/ - // [RequiresDynamicCode]); this converter is reachable ONLY through that - // serializer. JsonConverter.Read in the base is not annotated, so the - // requirement cannot be re-declared via [RequiresUnreferencedCode] here (it - // would trip IL2046) — the suppression points back to the contract that owns it. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "Reached only via the [RequiresUnreferencedCode] IAhpSerializer; the base JsonConverter.Read cannot carry the attribute.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "Reached only via the [RequiresDynamicCode] IAhpSerializer; the base JsonConverter.Read cannot carry the attribute.")] public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) @@ -74,7 +63,8 @@ protected UnionConverter( var result = new T(); if (disc is not null && _variants.TryGetValue(disc, out Type? variantType)) { - result.Value = root.Deserialize(variantType, options); + JsonTypeInfo typeInfo = options.GetTypeInfo(variantType); + result.Value = JsonSerializer.Deserialize(root, typeInfo); } else if (_allowUnknown) { @@ -91,14 +81,6 @@ protected UnionConverter( } /// - // JsonSerializer.Serialize(writer, inner, inner.GetType(), options) serializes - // by the boxed runtime type — genuinely trim/AOT-unsafe, for the same reason - // as Read above. Declared on the IAhpSerializer contract; suppressed here - // because the base JsonConverter.Write is not annotated. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "Reached only via the [RequiresUnreferencedCode] IAhpSerializer; the base JsonConverter.Write cannot carry the attribute.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "Reached only via the [RequiresDynamicCode] IAhpSerializer; the base JsonConverter.Write cannot carry the attribute.")] public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { object? inner = value?.Value; @@ -117,6 +99,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions // Serialize by the runtime type so every property (including the // variant's own discriminator field) is written. - JsonSerializer.Serialize(writer, inner, inner.GetType(), options); + JsonTypeInfo typeInfo = options.GetTypeInfo(inner.GetType()); + JsonSerializer.Serialize(writer, inner, typeInfo); } } diff --git a/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj b/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj index cb44c7f00..51bd1754b 100644 --- a/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj +++ b/clients/dotnet/src/AgentHostProtocol/AgentHostProtocol.csproj @@ -7,14 +7,10 @@ true Microsoft.AgentHostProtocol The Agent Host Protocol (AHP) client for .NET: an async JSON-RPC client, the pure state reducers, the default System.Text.Json serializer, a ClientWebSocket transport, and the multi-host runtime. - + + true + true true true @@ -33,6 +29,7 @@ + diff --git a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs index ab1998f60..e90eb47d0 100644 --- a/clients/dotnet/src/AgentHostProtocol/AhpClient.cs +++ b/clients/dotnet/src/AgentHostProtocol/AhpClient.cs @@ -52,8 +52,14 @@ private KeepAlivePolicy(bool isEnabled, TimeSpan interval, TimeSpan timeout) /// Periodically send a transport-level ping. Mirrors Swift /// .ping(interval:timeout:). /// - public static KeepAlivePolicy Ping(TimeSpan interval, TimeSpan timeout) => - new(isEnabled: true, interval: interval, timeout: timeout); + public static KeepAlivePolicy Ping(TimeSpan interval, TimeSpan timeout) + { + if (interval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(interval), interval, "Keep-alive interval must be positive."); + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Keep-alive timeout must be positive."); + return new(isEnabled: true, interval: interval, timeout: timeout); + } /// /// Convenience for the common WebSocket ping policy (30 s interval, 5 s @@ -72,6 +78,13 @@ public sealed class ClientConfig /// public TimeSpan DefaultRequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// + /// Supplies time for request timeouts, keep-alive scheduling, reconnect + /// backoff, and host timestamps. Defaults to . + /// Override in tests to advance time deterministically. + /// + public TimeProvider TimeProvider { get; set; } = TimeProvider.System; + /// /// Capacity of each subscription's event channel. Excess events are dropped /// on a full channel (mirrors Go's SubscriptionBuffer). Defaults to 256. @@ -87,6 +100,20 @@ public sealed class ClientConfig /// Returns a config with sensible defaults (30 s timeout, 256-message buffer). public static ClientConfig Default => new(); + + internal static ClientConfig Snapshot(ClientConfig? config) + { + var source = config ?? Default; + return new ClientConfig + { + DefaultRequestTimeout = source.DefaultRequestTimeout, + TimeProvider = source.TimeProvider ?? TimeProvider.System, + SubscriptionBufferCapacity = source.SubscriptionBufferCapacity > 0 + ? source.SubscriptionBufferCapacity + : 256, + KeepAlive = source.KeepAlive ?? KeepAlivePolicy.Disabled, + }; + } } // ─── Connection state ────────────────────────────────────────────────────────── @@ -226,11 +253,16 @@ private sealed class OutboundMessage { public JsonRpcMessage Message { get; } public TaskCompletionSource? Sent { get; } + public CancellationToken CancellationToken { get; } - public OutboundMessage(JsonRpcMessage message, TaskCompletionSource? sent = null) + public OutboundMessage( + JsonRpcMessage message, + TaskCompletionSource? sent = null, + CancellationToken cancellationToken = default) { Message = message; Sent = sent; + CancellationToken = cancellationToken; } } @@ -254,14 +286,15 @@ private AhpClient(ITransport transport, ClientConfig cfg, IAhpSerializer seriali // `startKeepAliveIfNeeded()` guard (`case .ping` + `as? AHPKeepAliveTransport`). if (_cfg.KeepAlive.IsEnabled && _transport is IKeepAliveTransport pingTransport) { - _keepAliveTask = Task.Run(() => RunKeepAliveAsync(pingTransport)); + _keepAliveTask = RunKeepAliveAsync(pingTransport); } } /// /// Wires to a new and /// starts the background reader / writer tasks. The client owns the transport - /// from this point. + /// from this point. The configuration is snapshotted; subsequent changes to + /// do not affect the connected client. /// public static AhpClient Connect( ITransport transport, @@ -269,8 +302,7 @@ public static AhpClient Connect( IAhpSerializer? serializer = null) { Guard.ThrowIfNull(transport, nameof(transport)); - var cfg = config ?? ClientConfig.Default; - if (cfg.SubscriptionBufferCapacity <= 0) cfg.SubscriptionBufferCapacity = 256; + var cfg = ClientConfig.Snapshot(config); return new AhpClient(transport, cfg, serializer ?? SystemTextJsonAhpSerializer.Default); } @@ -472,7 +504,10 @@ private async Task RunKeepAliveAsync(IKeepAliveTransport pingTransport) { while (!ct.IsCancellationRequested) { - await Task.Delay(policy.Interval, ct).ConfigureAwait(false); + await TimeProviderCompatibility.DelayAsync( + _cfg.TimeProvider, + policy.Interval, + ct).ConfigureAwait(false); if (ct.IsCancellationRequested) return; await pingTransport.SendPingAsync(policy.Timeout, ct).ConfigureAwait(false); } @@ -496,27 +531,28 @@ await ShutdownWithErrorAsync( // ── Writer loop ─────────────────────────────────────────────────────── - // The client routes all JSON through the injected IAhpSerializer, whose - // contract is [RequiresUnreferencedCode]/[RequiresDynamicCode] (the default - // SystemTextJsonAhpSerializer is reflection-based). The trim/AOT unsafety is - // declared at that contract; re-declaring it on this internal loop — or - // propagating the attribute up through the constructor / Connect() / the - // entire client + multi-host public surface — is out of scope here, so the - // serializer-call warnings are suppressed at the call site with that - // contract named as the owner. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] private async Task RunWriterAsync() { try { await foreach (var item in _outbound.Reader.ReadAllAsync(_lifetimeCts.Token).ConfigureAwait(false)) { + if (item.CancellationToken.IsCancellationRequested) + { + item.Sent?.TrySetCanceled(item.CancellationToken); + continue; + } + var frame = _serializer.EncodeMessage(item.Message); + if (item.CancellationToken.IsCancellationRequested) + { + item.Sent?.TrySetCanceled(item.CancellationToken); + continue; + } try { + // Canceling ClientWebSocket.SendAsync aborts the shared connection, + // so message cancellation only prevents writes that have not started. await _transport.SendAsync(frame, _lifetimeCts.Token).ConfigureAwait(false); item.Sent?.TrySetResult(true); } @@ -545,13 +581,6 @@ private async Task RunWriterAsync() // ── Reader loop ─────────────────────────────────────────────────────── - // See RunWriterAsync: JSON decode goes through the [RequiresUnreferencedCode]/ - // [RequiresDynamicCode] IAhpSerializer, which owns the trim/AOT-unsafety - // declaration. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] private async Task RunReaderAsync() { try @@ -654,12 +683,6 @@ private void Deliver(ulong id, JsonElement result, AhpRpcException? rpcError) /// handler is installed, otherwise the handler's result (or its thrown error). /// Mirrors the TS client's handleServerRequest. /// - // See RunWriterAsync: the handler's result serialize goes through the - // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] private async Task HandleServerRequestAsync(JsonRpcRequest req) { var handler = _serverRequestHandler; @@ -715,12 +738,6 @@ private async Task EnqueueReplyAsync(JsonRpcMessage msg) catch { /* shutting down — best effort */ } } - // See RunWriterAsync: the per-notification deserialize calls go through the - // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] private void HandleNotification(JsonRpcNotification n) { if (n.Params is null) return; @@ -821,12 +838,6 @@ private void FanOut(string channel, SubscriptionEvent ev) /// would be non-null. /// /// - // See RunWriterAsync: param serialize + result deserialize go through the - // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] public async Task RequestAsync( string method, TParams parameters, @@ -844,6 +855,17 @@ private void FanOut(string channel, SubscriptionEvent ev) // minted and the pending entry is registered. cancellationToken.ThrowIfCancellationRequested(); + // Validate and arm the timeout before minting an id or registering pending + // state. If a provider rejects the duration, no bookkeeping can leak. + using var timeoutCts = _cfg.DefaultRequestTimeout > TimeSpan.Zero + ? TimeProviderCompatibility.CreateCancellationTokenSource( + _cfg.TimeProvider, + _cfg.DefaultRequestTimeout) + : null; + using var requestCts = timeoutCts is null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + // Gate the span-name string-build on HasListeners so the no-listener path // stays allocation-free; the method goes in the span name (OTel "{op} {target}") // and as the rpc.method tag. @@ -885,7 +907,7 @@ private void FanOut(string channel, SubscriptionEvent ev) try { - await SendMessageAsync(req, cancellationToken).ConfigureAwait(false); + await SendMessageAsync(req, requestCts.Token).ConfigureAwait(false); } catch { @@ -894,15 +916,9 @@ private void FanOut(string channel, SubscriptionEvent ev) } AhpTelemetry.MessagesSent.Add(1, new KeyValuePair(AhpTelemetryNames.AttrMessageKind, AhpTelemetryNames.MessageKindRequest)); - // Always apply the configured default timeout when positive, composing it - // with any caller-supplied cancellation token. - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - if (_cfg.DefaultRequestTimeout > TimeSpan.Zero) - linkedCts.CancelAfter(_cfg.DefaultRequestTimeout); - try { - var resultEl = await tcs.Task.WaitAsync(linkedCts.Token).ConfigureAwait(false); + var resultEl = await tcs.Task.WaitAsync(requestCts.Token).ConfigureAwait(false); activity?.SetStatus(ActivityStatusCode.Ok); outcome = AhpTelemetryNames.OutcomeOk; if (resultEl.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) @@ -944,12 +960,6 @@ private void FanOut(string channel, SubscriptionEvent ev) /// /// Sends a JSON-RPC notification (fire-and-forget). /// - // See RunWriterAsync: param serialize goes through the - // [RequiresUnreferencedCode]/[RequiresDynamicCode] IAhpSerializer contract. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] public async Task NotifyAsync( string method, TParams parameters, @@ -978,7 +988,7 @@ public async Task NotifyAsync( private async Task SendMessageAsync(JsonRpcMessage msg, CancellationToken cancellationToken) { var sentTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var item = new OutboundMessage(msg, sentTcs); + var item = new OutboundMessage(msg, sentTcs, cancellationToken); try { @@ -1026,9 +1036,16 @@ public async Task InitializeAsync( // The protocol requires a result for `initialize`; a null/empty result is // a protocol violation, surfaced loudly rather than returned as null. - return await RequestAsync("initialize", @params, cancellationToken) + var result = await RequestAsync("initialize", @params, cancellationToken) .ConfigureAwait(false) ?? throw new AhpRpcException(JsonRpcErrorCodes.InternalError, "ahp: initialize returned no result"); + if (!versions.Contains(result.ProtocolVersion)) + { + throw new AhpTransportException( + "protocol", + $"ahp: server selected unoffered protocol version '{result.ProtocolVersion}'"); + } + return result; } /// Re-establishes a dropped connection via the reconnect flow. @@ -1340,10 +1357,6 @@ public void SetResourceRequestHandlers(ResourceRequestHandlers? handlers) => // Decode the raw params into the typed record and dispatch to the matching // handler. An unset method rejects with MethodNotFound so the peer sees the // same reply as the no-handler path. - [UnconditionalSuppressMessage("Trimming", "IL2026", - Justification = "JSON goes through the [RequiresUnreferencedCode] IAhpSerializer, which declares the reflection unsafety on its contract.")] - [UnconditionalSuppressMessage("AOT", "IL3050", - Justification = "JSON goes through the [RequiresDynamicCode] IAhpSerializer, which declares the AOT unsafety on its contract.")] private async Task DispatchResourceRequestAsync( ResourceRequestHandlers handlers, string method, JsonElement? prms) { diff --git a/clients/dotnet/src/AgentHostProtocol/Errors.cs b/clients/dotnet/src/AgentHostProtocol/Errors.cs index 2b570b016..78e89c18f 100644 --- a/clients/dotnet/src/AgentHostProtocol/Errors.cs +++ b/clients/dotnet/src/AgentHostProtocol/Errors.cs @@ -28,12 +28,10 @@ public sealed class AhpTransportException : AhpException { /// /// Classifies the failure. Mirrors the Go TransportError.Kind field, whose - /// vocabulary is "closed", "io", and "protocol". This client - /// raises "closed" and "io"; it deliberately does not raise - /// "protocol" — where Go surfaces a protocol error on a frame it cannot - /// decode, this client skips the malformed frame and resyncs (counted by the - /// ahp.client.frames.malformed metric). A "protocol" value may still - /// be observed if a server reports one. + /// vocabulary is "closed", "io", and "protocol". + /// Malformed frames are skipped and counted by the + /// ahp.client.frames.malformed metric, while handshake violations such + /// as selecting an unoffered protocol version raise "protocol". /// public string Kind { get; } diff --git a/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs new file mode 100644 index 000000000..2bcda2d59 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/Generated/ActionMetadata.generated.cs @@ -0,0 +1,464 @@ +// +// Generated from types/*.ts — do not edit. +// +// Regenerate with: npm run generate:dotnet +// +#nullable enable + +namespace Microsoft.AgentHostProtocol; + +internal static class GeneratedActionMetadata +{ + public static bool TryGetActionType(object action, out ActionType actionType) + { + switch (action) + { + case AnnotationsEntryRemovedAction value: + actionType = value.Type; + return true; + case AnnotationsEntrySetAction value: + actionType = value.Type; + return true; + case AnnotationsRemovedAction value: + actionType = value.Type; + return true; + case AnnotationsSetAction value: + actionType = value.Type; + return true; + case AnnotationsUpdatedAction value: + actionType = value.Type; + return true; + case AutomationCreateRequestedAction value: + actionType = value.Type; + return true; + case AutomationRemovedAction value: + actionType = value.Type; + return true; + case AutomationRunCancelRequestedAction value: + actionType = value.Type; + return true; + case AutomationRunLifecycleChangedAction value: + actionType = value.Type; + return true; + case AutomationRunPrimarySessionChangedAction value: + actionType = value.Type; + return true; + case AutomationRunSessionRemovedAction value: + actionType = value.Type; + return true; + case AutomationRunSessionSetAction value: + actionType = value.Type; + return true; + case AutomationSetAction value: + actionType = value.Type; + return true; + case AutomationUpdateRequestedAction value: + actionType = value.Type; + return true; + case ChangesetClearedAction value: + actionType = value.Type; + return true; + case ChangesetContentChangedAction value: + actionType = value.Type; + return true; + case ChangesetFileRemovedAction value: + actionType = value.Type; + return true; + case ChangesetFileSetAction value: + actionType = value.Type; + return true; + case ChangesetFilesReviewChangedAction value: + actionType = value.Type; + return true; + case ChangesetOperationsChangedAction value: + actionType = value.Type; + return true; + case ChangesetOperationStatusChangedAction value: + actionType = value.Type; + return true; + case ChangesetStatusChangedAction value: + actionType = value.Type; + return true; + case ChatActivityChangedAction value: + actionType = value.Type; + return true; + case ChatDeltaAction value: + actionType = value.Type; + return true; + case ChatDraftChangedAction value: + actionType = value.Type; + return true; + case ChatErrorAction value: + actionType = value.Type; + return true; + case ChatInputAnswerChangedAction value: + actionType = value.Type; + return true; + case ChatInputCompletedAction value: + actionType = value.Type; + return true; + case ChatInputRequestedAction value: + actionType = value.Type; + return true; + case ChatPendingMessageRemovedAction value: + actionType = value.Type; + return true; + case ChatPendingMessageSetAction value: + actionType = value.Type; + return true; + case ChatQueuedMessagesReorderedAction value: + actionType = value.Type; + return true; + case ChatReasoningAction value: + actionType = value.Type; + return true; + case ChatResponsePartAction value: + actionType = value.Type; + return true; + case ChatToolCallAuthRequiredAction value: + actionType = value.Type; + return true; + case ChatToolCallAuthResolvedAction value: + actionType = value.Type; + return true; + case ChatToolCallCompleteAction value: + actionType = value.Type; + return true; + case ChatToolCallConfirmedAction value: + actionType = value.Type; + return true; + case ChatToolCallContentChangedAction value: + actionType = value.Type; + return true; + case ChatToolCallDeltaAction value: + actionType = value.Type; + return true; + case ChatToolCallReadyAction value: + actionType = value.Type; + return true; + case ChatToolCallResultConfirmedAction value: + actionType = value.Type; + return true; + case ChatToolCallStartAction value: + actionType = value.Type; + return true; + case ChatTruncatedAction value: + actionType = value.Type; + return true; + case ChatTurnCancelledAction value: + actionType = value.Type; + return true; + case ChatTurnCompleteAction value: + actionType = value.Type; + return true; + case ChatTurnsLoadedAction value: + actionType = value.Type; + return true; + case ChatTurnStartedAction value: + actionType = value.Type; + return true; + case ChatUsageAction value: + actionType = value.Type; + return true; + case ChatWorkingDirectoryRemovedAction value: + actionType = value.Type; + return true; + case ChatWorkingDirectorySetAction value: + actionType = value.Type; + return true; + case ResourceWatchChangedAction value: + actionType = value.Type; + return true; + case RootActiveSessionsChangedAction value: + actionType = value.Type; + return true; + case RootAgentsChangedAction value: + actionType = value.Type; + return true; + case RootConfigChangedAction value: + actionType = value.Type; + return true; + case RootTerminalsChangedAction value: + actionType = value.Type; + return true; + case SessionActiveClientRemovedAction value: + actionType = value.Type; + return true; + case SessionActiveClientSetAction value: + actionType = value.Type; + return true; + case SessionActivityChangedAction value: + actionType = value.Type; + return true; + case SessionChangesetsChangedAction value: + actionType = value.Type; + return true; + case SessionChatAddedAction value: + actionType = value.Type; + return true; + case SessionChatRemovedAction value: + actionType = value.Type; + return true; + case SessionChatUpdatedAction value: + actionType = value.Type; + return true; + case SessionConfigChangedAction value: + actionType = value.Type; + return true; + case SessionCreationFailedAction value: + actionType = value.Type; + return true; + case SessionCustomizationRemovedAction value: + actionType = value.Type; + return true; + case SessionCustomizationsChangedAction value: + actionType = value.Type; + return true; + case SessionCustomizationToggledAction value: + actionType = value.Type; + return true; + case SessionCustomizationUpdatedAction value: + actionType = value.Type; + return true; + case SessionDefaultChatChangedAction value: + actionType = value.Type; + return true; + case SessionDeltaAction value: + actionType = value.Type; + return true; + case SessionErrorAction value: + actionType = value.Type; + return true; + case SessionInputNeededRemovedAction value: + actionType = value.Type; + return true; + case SessionInputNeededSetAction value: + actionType = value.Type; + return true; + case SessionIsArchivedChangedAction value: + actionType = value.Type; + return true; + case SessionIsReadChangedAction value: + actionType = value.Type; + return true; + case SessionMcpServerStartRequestedAction value: + actionType = value.Type; + return true; + case SessionMcpServerStateChangedAction value: + actionType = value.Type; + return true; + case SessionMcpServerStopRequestedAction value: + actionType = value.Type; + return true; + case SessionMetaChangedAction value: + actionType = value.Type; + return true; + case SessionPendingMessageRemovedAction value: + actionType = value.Type; + return true; + case SessionPendingMessageSetAction value: + actionType = value.Type; + return true; + case SessionQueuedMessagesReorderedAction value: + actionType = value.Type; + return true; + case SessionReadyAction value: + actionType = value.Type; + return true; + case SessionReasoningAction value: + actionType = value.Type; + return true; + case SessionResponsePartAction value: + actionType = value.Type; + return true; + case SessionServerToolsChangedAction value: + actionType = value.Type; + return true; + case SessionTitleChangedAction value: + actionType = value.Type; + return true; + case SessionToolCallCompleteAction value: + actionType = value.Type; + return true; + case SessionToolCallConfirmedAction value: + actionType = value.Type; + return true; + case SessionToolCallContentChangedAction value: + actionType = value.Type; + return true; + case SessionToolCallDeltaAction value: + actionType = value.Type; + return true; + case SessionToolCallReadyAction value: + actionType = value.Type; + return true; + case SessionToolCallResultConfirmedAction value: + actionType = value.Type; + return true; + case SessionToolCallStartAction value: + actionType = value.Type; + return true; + case SessionTruncatedAction value: + actionType = value.Type; + return true; + case SessionTurnCancelledAction value: + actionType = value.Type; + return true; + case SessionTurnCompleteAction value: + actionType = value.Type; + return true; + case SessionTurnStartedAction value: + actionType = value.Type; + return true; + case SessionUsageAction value: + actionType = value.Type; + return true; + case SessionWorkingDirectoryRemovedAction value: + actionType = value.Type; + return true; + case SessionWorkingDirectoryReplacedAction value: + actionType = value.Type; + return true; + case SessionWorkingDirectorySetAction value: + actionType = value.Type; + return true; + case TerminalClaimedAction value: + actionType = value.Type; + return true; + case TerminalClearedAction value: + actionType = value.Type; + return true; + case TerminalCommandDetectionAvailableAction value: + actionType = value.Type; + return true; + case TerminalCommandExecutedAction value: + actionType = value.Type; + return true; + case TerminalCommandFinishedAction value: + actionType = value.Type; + return true; + case TerminalCwdChangedAction value: + actionType = value.Type; + return true; + case TerminalDataAction value: + actionType = value.Type; + return true; + case TerminalExitedAction value: + actionType = value.Type; + return true; + case TerminalInputAction value: + actionType = value.Type; + return true; + case TerminalResizedAction value: + actionType = value.Type; + return true; + case TerminalTitleChangedAction value: + actionType = value.Type; + return true; + default: + actionType = default; + return false; + } + } + + public static string GetWireName(ActionType actionType) => + actionType switch + { + ActionType.AnnotationsEntryRemoved => "annotations/entryRemoved", + ActionType.AnnotationsEntrySet => "annotations/entrySet", + ActionType.AnnotationsRemoved => "annotations/removed", + ActionType.AnnotationsSet => "annotations/set", + ActionType.AnnotationsUpdated => "annotations/updated", + ActionType.AutomationCreateRequested => "automation/createRequested", + ActionType.AutomationRemoved => "automation/removed", + ActionType.AutomationRunCancelRequested => "automationRun/cancelRequested", + ActionType.AutomationRunLifecycleChanged => "automationRun/lifecycleChanged", + ActionType.AutomationRunPrimarySessionChanged => "automationRun/primarySessionChanged", + ActionType.AutomationRunSessionRemoved => "automationRun/sessionRemoved", + ActionType.AutomationRunSessionSet => "automationRun/sessionSet", + ActionType.AutomationSet => "automation/set", + ActionType.AutomationUpdateRequested => "automation/updateRequested", + ActionType.ChangesetCleared => "changeset/cleared", + ActionType.ChangesetContentChanged => "changeset/contentChanged", + ActionType.ChangesetFileRemoved => "changeset/fileRemoved", + ActionType.ChangesetFileSet => "changeset/fileSet", + ActionType.ChangesetFilesReviewChanged => "changeset/filesReviewChanged", + ActionType.ChangesetOperationsChanged => "changeset/operationsChanged", + ActionType.ChangesetOperationStatusChanged => "changeset/operationStatusChanged", + ActionType.ChangesetStatusChanged => "changeset/statusChanged", + ActionType.ChatActivityChanged => "chat/activityChanged", + ActionType.ChatDelta => "chat/delta", + ActionType.ChatDraftChanged => "chat/draftChanged", + ActionType.ChatError => "chat/error", + ActionType.ChatInputAnswerChanged => "chat/inputAnswerChanged", + ActionType.ChatInputCompleted => "chat/inputCompleted", + ActionType.ChatInputRequested => "chat/inputRequested", + ActionType.ChatPendingMessageRemoved => "chat/pendingMessageRemoved", + ActionType.ChatPendingMessageSet => "chat/pendingMessageSet", + ActionType.ChatQueuedMessagesReordered => "chat/queuedMessagesReordered", + ActionType.ChatReasoning => "chat/reasoning", + ActionType.ChatResponsePart => "chat/responsePart", + ActionType.ChatToolCallAuthRequired => "chat/toolCallAuthRequired", + ActionType.ChatToolCallAuthResolved => "chat/toolCallAuthResolved", + ActionType.ChatToolCallComplete => "chat/toolCallComplete", + ActionType.ChatToolCallConfirmed => "chat/toolCallConfirmed", + ActionType.ChatToolCallContentChanged => "chat/toolCallContentChanged", + ActionType.ChatToolCallDelta => "chat/toolCallDelta", + ActionType.ChatToolCallReady => "chat/toolCallReady", + ActionType.ChatToolCallResultConfirmed => "chat/toolCallResultConfirmed", + ActionType.ChatToolCallStart => "chat/toolCallStart", + ActionType.ChatTruncated => "chat/truncated", + ActionType.ChatTurnCancelled => "chat/turnCancelled", + ActionType.ChatTurnComplete => "chat/turnComplete", + ActionType.ChatTurnsLoaded => "chat/turnsLoaded", + ActionType.ChatTurnStarted => "chat/turnStarted", + ActionType.ChatUsage => "chat/usage", + ActionType.ChatWorkingDirectoryRemoved => "chat/workingDirectoryRemoved", + ActionType.ChatWorkingDirectorySet => "chat/workingDirectorySet", + ActionType.ResourceWatchChanged => "resourceWatch/changed", + ActionType.RootActiveSessionsChanged => "root/activeSessionsChanged", + ActionType.RootAgentsChanged => "root/agentsChanged", + ActionType.RootConfigChanged => "root/configChanged", + ActionType.RootTerminalsChanged => "root/terminalsChanged", + ActionType.SessionActiveClientRemoved => "session/activeClientRemoved", + ActionType.SessionActiveClientSet => "session/activeClientSet", + ActionType.SessionActivityChanged => "session/activityChanged", + ActionType.SessionChangesetsChanged => "session/changesetsChanged", + ActionType.SessionChatAdded => "session/chatAdded", + ActionType.SessionChatRemoved => "session/chatRemoved", + ActionType.SessionChatUpdated => "session/chatUpdated", + ActionType.SessionConfigChanged => "session/configChanged", + ActionType.SessionCreationFailed => "session/creationFailed", + ActionType.SessionCustomizationRemoved => "session/customizationRemoved", + ActionType.SessionCustomizationsChanged => "session/customizationsChanged", + ActionType.SessionCustomizationToggled => "session/customizationToggled", + ActionType.SessionCustomizationUpdated => "session/customizationUpdated", + ActionType.SessionDefaultChatChanged => "session/defaultChatChanged", + ActionType.SessionInputNeededRemoved => "session/inputNeededRemoved", + ActionType.SessionInputNeededSet => "session/inputNeededSet", + ActionType.SessionIsArchivedChanged => "session/isArchivedChanged", + ActionType.SessionIsReadChanged => "session/isReadChanged", + ActionType.SessionMcpServerStartRequested => "session/mcpServerStartRequested", + ActionType.SessionMcpServerStateChanged => "session/mcpServerStateChanged", + ActionType.SessionMcpServerStopRequested => "session/mcpServerStopRequested", + ActionType.SessionMetaChanged => "session/metaChanged", + ActionType.SessionReady => "session/ready", + ActionType.SessionServerToolsChanged => "session/serverToolsChanged", + ActionType.SessionTitleChanged => "session/titleChanged", + ActionType.SessionWorkingDirectoryRemoved => "session/workingDirectoryRemoved", + ActionType.SessionWorkingDirectoryReplaced => "session/workingDirectoryReplaced", + ActionType.SessionWorkingDirectorySet => "session/workingDirectorySet", + ActionType.TerminalClaimed => "terminal/claimed", + ActionType.TerminalCleared => "terminal/cleared", + ActionType.TerminalCommandDetectionAvailable => "terminal/commandDetectionAvailable", + ActionType.TerminalCommandExecuted => "terminal/commandExecuted", + ActionType.TerminalCommandFinished => "terminal/commandFinished", + ActionType.TerminalCwdChanged => "terminal/cwdChanged", + ActionType.TerminalData => "terminal/data", + ActionType.TerminalExited => "terminal/exited", + ActionType.TerminalInput => "terminal/input", + ActionType.TerminalResized => "terminal/resized", + ActionType.TerminalTitleChanged => "terminal/titleChanged", + _ => throw new ArgumentOutOfRangeException(nameof(actionType)), + }; +} diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs index 7bd3046b8..5fb08764f 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/FileClientIdStore.cs @@ -2,27 +2,29 @@ // Faithful port of clients/swift/.../Hosts/ClientIdStore.swift (FileClientIdStore). // // One file per host id under a configurable directory; writes are atomic -// (temp file + File.Move overwrite, atomic on the same volume) and best-effort -// restrict permissions to owner-read/write on Unix so the persisted ids aren't -// world-readable. Per-store mutations are serialised through a SemaphoreSlim +// (temp file + File.Move overwrite, atomic on the same volume) and establish +// owner-read/write permissions before any bytes are written on Unix. Per-store +// mutations are serialised through a SemaphoreSlim // (mirroring Swift's `actor Storage`) so concurrent load/store calls from // different hosts don't race on the directory's contents. #nullable enable using System; +using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; namespace Microsoft.AgentHostProtocol.Hosts; /// /// Filesystem-backed that survives process /// restarts. Stores one <encoded-host-id>.clientid file per host -/// under ; writes are atomic and best-effort restricted -/// to owner-only permissions on Unix. Mirrors Swift's FileClientIdStore. +/// under ; writes are atomic and restricted to owner-only +/// permissions on Unix. Mirrors Swift's FileClientIdStore. /// /// /// For the highest-security profile on Apple platforms, wrap a keychain-backed @@ -33,11 +35,15 @@ namespace Microsoft.AgentHostProtocol.Hosts; /// filenames are derived from each host id via a percent-encoding helper so /// arbitrary strings (including :, /, etc.) /// map to safe filesystem paths. +/// On Unix, a write fails before persisting any client-ID bytes if owner-only +/// permissions cannot be established on the temporary file. /// public sealed class FileClientIdStore : IClientIdStore, IDisposable { // Serialises mutations across hosts (mirrors Swift's `actor Storage`). private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Action? _tempFileReadyForWrite; + private readonly bool _useNativeUnixFileCreation; /// The directory this store persists client-id files under. public string Directory { get; } @@ -49,9 +55,23 @@ public sealed class FileClientIdStore : IClientIdStore, IDisposable /// desktop platforms, XDG_DATA_HOME / ~/.local/share on Linux). /// public FileClientIdStore(string directory) + : this(directory, tempFileReadyForWrite: null) + { + } + + internal FileClientIdStore( + string directory, + Action? tempFileReadyForWrite, + bool useNativeUnixFileCreation = false) { Guard.ThrowIfNull(directory, nameof(directory)); Directory = directory; + _tempFileReadyForWrite = tempFileReadyForWrite; +#if NET8_0_OR_GREATER + _useNativeUnixFileCreation = useNativeUnixFileCreation; +#else + _useNativeUnixFileCreation = true; +#endif } /// @@ -114,23 +134,15 @@ public async Task StoreAsync(HostId host, string clientId, CancellationToken can var tempPath = Path.Combine(Directory, "." + Guid.NewGuid().ToString("N") + ".tmp"); try { - using (var stream = new FileStream( - tempPath, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - bufferSize: 4096, - useAsync: true)) + using (var stream = CreateTempFile(tempPath)) { + _tempFileReadyForWrite?.Invoke(tempPath); #if NETSTANDARD2_0 await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); #else await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); #endif } - // Set owner-only perms on the temp file BEFORE the move so the - // destination is never momentarily world-readable. - TrySetOwnerOnlyFile(tempPath); #if NETSTANDARD2_0 if (File.Exists(path)) { @@ -186,25 +198,58 @@ private void EnsureDirectory() /// private static string Encode(HostId host) => HostedResourceKey.PercentEscape(host.ToString()); - // ── Best-effort owner-only permissions (no-op off Unix) ─────────────────── - - private static void TrySetOwnerOnlyFile(string path) + private FileStream CreateTempFile(string path) { #if NET8_0_OR_GREATER if (!OperatingSystem.IsWindows()) { - try { File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); } - catch { /* best-effort: ignore on platforms/filesystems that reject it */ } + if (_useNativeUnixFileCreation) + { + return CreateSecureUnixTempFile(path); + } + + var stream = new FileStream(path, new FileStreamOptions + { + Access = FileAccess.Write, + Mode = FileMode.CreateNew, + Share = FileShare.None, + BufferSize = 4096, + Options = FileOptions.Asynchronous, + UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite, + }); + try + { + // UnixCreateMode applies 0600 atomically at creation. Normalize + // the exact mode before exposing the stream to any write. + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + return stream; + } + catch + { + stream.Dispose(); + throw; + } } #else if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - try { ChmodUtf8(path, Convert.ToUInt32("600", 8)); } - catch { /* best-effort */ } + return _useNativeUnixFileCreation + ? CreateSecureUnixTempFile(path) + : throw new InvalidOperationException("Native Unix file creation must be enabled for netstandard2.0."); } #endif + + return new FileStream( + path, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true); } + // ── Best-effort owner-only directory permissions (no-op off Unix) ───────── + private static void TrySetOwnerOnlyDirectory(string path) { #if NET8_0_OR_GREATER @@ -227,26 +272,104 @@ private static void TrySetOwnerOnlyDirectory(string path) #endif } + [DllImport("libc", EntryPoint = "open", ExactSpelling = true, SetLastError = true)] + private static extern int Open(IntPtr path, int flags, uint mode); + + [DllImport("libc", EntryPoint = "fchmod", ExactSpelling = true, SetLastError = true)] + private static extern int Fchmod(int fileDescriptor, uint mode); + #if NETSTANDARD2_0 [DllImport("libc", EntryPoint = "chmod", ExactSpelling = true, SetLastError = true)] private static extern int Chmod(IntPtr path, uint mode); +#endif + private static FileStream CreateSecureUnixTempFile(string path) + { + const uint OwnerReadWrite = 0x180; // 0600 + const int WriteOnly = 0x0001; + const int LinuxCreateExclusive = 0x0040 | 0x0080; + const int BsdCreateExclusive = 0x0200 | 0x0800; + const int LinuxCloseOnExec = 0x00080000; + const int MacOsCloseOnExec = 0x01000000; + const int FreeBsdCloseOnExec = 0x00100000; + + int flags; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + flags = WriteOnly | LinuxCreateExclusive | LinuxCloseOnExec; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + flags = WriteOnly | BsdCreateExclusive | MacOsCloseOnExec; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("FREEBSD"))) + { + flags = WriteOnly | BsdCreateExclusive | FreeBsdCloseOnExec; + } + else + { + throw new PlatformNotSupportedException( + "Secure client ID storage requires a known atomic O_CLOEXEC value on this Unix platform."); + } + + int fileDescriptor = WithUtf8Path( + path, + nativePath => Open(nativePath, flags, OwnerReadWrite)); + if (fileDescriptor < 0) + { + throw CreateUnixIOException("create secure temporary file", path); + } + +#pragma warning disable CA2000 // Ownership transfers to the returned FileStream. + var handle = new SafeFileHandle((IntPtr)fileDescriptor, ownsHandle: true); +#pragma warning restore CA2000 + try + { + if (Fchmod(fileDescriptor, OwnerReadWrite) != 0) + { + throw CreateUnixIOException("set owner-only permissions on temporary file", path); + } + + // open(2) returns a synchronous descriptor. FileStream still supports + // WriteAsync on it, but must not be told the handle uses overlapped I/O. + return new FileStream(handle, FileAccess.Write, bufferSize: 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + +#if NETSTANDARD2_0 private static void ChmodUtf8(string path, uint mode) + { + _ = WithUtf8Path(path, nativePath => Chmod(nativePath, mode)); + } +#endif + + private static T WithUtf8Path(string path, Func action) { byte[] bytes = Encoding.UTF8.GetBytes(path + "\0"); IntPtr nativePath = Marshal.AllocHGlobal(bytes.Length); try { Marshal.Copy(bytes, 0, nativePath, bytes.Length); - _ = Chmod(nativePath, mode); + return action(nativePath); } finally { Marshal.FreeHGlobal(nativePath); } } -#endif + private static IOException CreateUnixIOException(string operation, string path) + { + int error = Marshal.GetLastWin32Error(); + return new IOException( + $"Failed to {operation} '{path}'.", + new Win32Exception(error)); + } private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs index de1ad2404..2e68fa748 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostConfig.cs @@ -28,12 +28,32 @@ public sealed class HostConfig /// Tunes the underlying driver. public ClientConfig? ClientConfig { get; init; } - /// Opens a transport for this host. Required. - public HostTransportFactory? TransportFactory { get; init; } + /// + /// Opens a transport for this host. Required — declared with the C# + /// required modifier so callers cannot accidentally omit the + /// connection factory. + /// + public required HostTransportFactory TransportFactory { get; init; } /// Controls reconnect behaviour on drops. Defaults to . public ReconnectPolicy? ReconnectPolicy { get; init; } /// Protocol versions advertised on initialize. Defaults to . public IReadOnlyList? ProtocolVersions { get; init; } + + internal HostConfig Snapshot(string clientId) => new() + { + Id = Id, + Label = Label, + ClientId = clientId, + InitialSubscriptions = InitialSubscriptions is { Count: > 0 } + ? new List(InitialSubscriptions) + : new[] { ProtocolVersion.RootResourceUri }, + ClientConfig = Microsoft.AgentHostProtocol.ClientConfig.Snapshot(ClientConfig), + TransportFactory = TransportFactory, + ReconnectPolicy = ReconnectPolicy ?? Microsoft.AgentHostProtocol.Hosts.ReconnectPolicy.Default, + ProtocolVersions = ProtocolVersions is { Count: > 0 } + ? new List(ProtocolVersions) + : new List(ProtocolVersion.Supported), + }; } diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs index 26f38be7a..5620be68e 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostId.cs @@ -29,6 +29,12 @@ public HostId(string value) /// public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value); + /// Returns true when both host IDs have the same ordinal value. + public static bool operator ==(HostId? left, HostId? right) => Equals(left, right); + + /// Returns true when the host IDs have different ordinal values. + public static bool operator !=(HostId? left, HostId? right) => !Equals(left, right); + /// Implicit conversion from string. public static implicit operator HostId(string s) => new(s); } diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs index ff7c56c4c..2ba43f18f 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/HostedResourceKey.cs @@ -47,7 +47,7 @@ private static bool IsUnreserved(char c) => /// Percent-escapes per RFC 3986 (UTF-8 bytes; uppercase /// hex digits, matching the RFC's normalized form). /// - public static string PercentEscape(string value) + internal static string PercentEscape(string value) { Guard.ThrowIfNull(value, nameof(value)); var sb = new StringBuilder(value.Length); diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs index a0bdf421a..9e7ce14bf 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostClient.cs @@ -25,9 +25,11 @@ internal sealed class HostEntry : IDisposable // Published reference, read lock-free via CurrentClient. A reference read is // atomic; `volatile` supplies the visibility a lock would otherwise provide. private volatile AhpClient? _client; + private TaskCompletionSource _clientReady = + new(TaskCreationOptions.RunContinuationsAsynchronously); private HostState _state = new() { Kind = HostStateKind.Disconnected }; private string _protoVer = ""; - private DateTimeOffset _updatedAt = DateTimeOffset.UtcNow; + private DateTimeOffset _updatedAt; // ── Swift-parity observable per-host state (guarded by _gate) ────────── // Session summaries are keyed by their `Resource` URI so add/remove/change @@ -38,12 +40,14 @@ internal sealed class HostEntry : IDisposable private List _agents = new(); private long? _activeSessions; private readonly List _subscriptions; + private readonly TimeProvider _timeProvider; private long _serverSeq; private DateTimeOffset? _lastConnectedAt; private ulong _generation; public CancellationTokenSource LifetimeCts { get; } = new(); public Task SupervisorTask { get; set; } = Task.CompletedTask; + public SemaphoreSlim ConnectionGate { get; } = new(1, 1); /// Task for the fire-and-forget pump loop started in OpenHostAsync. public Task PumpTask { get; set; } = Task.CompletedTask; @@ -152,6 +156,7 @@ public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; LifetimeCts.Dispose(); + ConnectionGate.Dispose(); _manualReconnect.Dispose(); // EndAttempt normally disposes the per-attempt CTS, but a teardown that // interrupts an in-flight attempt may leave one set; dispose it too. @@ -165,6 +170,8 @@ public void Dispose() public HostEntry(HostId id, HostConfig config, string clientId) { Id = id; Config = config; ClientId = clientId; + _timeProvider = config.ClientConfig?.TimeProvider ?? TimeProvider.System; + _updatedAt = _timeProvider.GetUtcNow(); // Seed the replay subscription set from the normalized config so it // survives reconnects (mirrors Swift HostRuntime seeding `subscriptions` // from `config.initialSubscriptions`). @@ -184,12 +191,72 @@ public void SetClient(AhpClient? client, string protoVer) { // _protoVer is read together with _state/_updatedAt by Snapshot(), so the // write stays under the lock; the _client write is a volatile publish. - lock (_gate) { _client = client; _protoVer = protoVer; } + lock (_gate) + { + _client = client; + _protoVer = protoVer; + if (client is null) + { + if (_clientReady.Task.IsCompleted) + _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + else + { + _clientReady.TrySetResult(client); + } + } + } + + public async Task WaitForClientAsync(CancellationToken cancellationToken) + { + Task ready; + lock (_gate) + { + if (_client is { } client) return client; + if (_state.Kind is not HostStateKind.Connecting and not HostStateKind.Reconnecting) + throw new HostNotConnectedException(Id); + ready = _clientReady.Task; + } + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + LifetimeCts.Token); + return await ready.WaitAsync(linkedCts.Token).ConfigureAwait(false) + ?? throw new HostNotConnectedException(Id); } public void SetState(HostState state) { - lock (_gate) { _state = state; _updatedAt = DateTimeOffset.UtcNow; } + lock (_gate) + { + _state = state; + _updatedAt = _timeProvider.GetUtcNow(); + if (_client is null) + { + if (state.Kind is HostStateKind.Connecting or HostStateKind.Reconnecting) + { + if (_clientReady.Task.IsCompleted) + _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + else + { + _clientReady.TrySetResult(null); + } + } + } + } + + public void BeginReconnect(HostState state) + { + lock (_gate) + { + _state = state; + _updatedAt = _timeProvider.GetUtcNow(); + _client = null; + _protoVer = ""; + if (_clientReady.Task.IsCompleted) + _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + } } /// An immutable, consistent snapshot of this host's public state. @@ -241,7 +308,7 @@ public ulong ApplyConnected(RootState? root, long serverSeq) lock (_gate) { _generation += 1; - _lastConnectedAt = DateTimeOffset.UtcNow; + _lastConnectedAt = _timeProvider.GetUtcNow(); _serverSeq = serverSeq; if (root is not null) { @@ -323,6 +390,43 @@ public void RemoveSubscription(string uri) { lock (_gate) { _subscriptions.Remove(uri); } } + + /// Drops URIs that the server could not resume. + public void RemoveSubscriptions(IEnumerable uris) + { + lock (_gate) + { + foreach (var uri in uris) _subscriptions.Remove(uri); + } + } + + /// Advances the last observed server sequence without moving it backward. + public void AdvanceServerSeq(long serverSeq) + { + lock (_gate) + { + if (serverSeq > _serverSeq) _serverSeq = serverSeq; + } + } + + /// Applies root actions represented by the public host snapshot. + public bool ApplyRootAction(StateAction action) + { + lock (_gate) + { + switch (action.Value) + { + case RootAgentsChangedAction agents: + _agents = new List(agents.Agents); + return true; + case RootActiveSessionsChangedAction sessions: + _activeSessions = sessions.ActiveSessions; + return true; + default: + return false; + } + } + } } // ─── MultiHostClient ───────────────────────────────────────────────────────── @@ -429,7 +533,9 @@ public MultiHostClient WithClientIdStore(IClientIdStore store) /// /// Registers , opens its initial transport, runs the /// initialize handshake, and starts the reconnect supervisor. Returns a - /// fresh snapshot. + /// fresh snapshot. The configuration is snapshotted; + /// subsequent changes to its nested client configuration or collection values + /// do not affect reconnects. /// public async Task AddHostAsync( HostConfig config, @@ -440,6 +546,9 @@ public async Task AddHostAsync( if (config.TransportFactory is null) throw new ArgumentException($"HostConfig.TransportFactory is required for {config.Id}.", nameof(config)); + // Take ownership of all caller-mutable state before the first await. + var ownedConfig = config.Snapshot(config.ClientId ?? ""); + // After shutdown, adding a host is rejected with HostShutDownException // carrying the would-be host id (mirrors Swift `add` throwing // `.hostShutDown(id)` once `didShutDown`). @@ -448,44 +557,29 @@ public async Task AddHostAsync( if (_didShutDown) throw new HostShutDownException(config.Id); } - var policy = config.ReconnectPolicy ?? ReconnectPolicy.Default; - var initialSubs = config.InitialSubscriptions is { Count: > 0 } - ? config.InitialSubscriptions - : new[] { ProtocolVersion.RootResourceUri }; - var protoVersions = config.ProtocolVersions is { Count: > 0 } - ? config.ProtocolVersions - : ProtocolVersion.Supported; - // Resolve or mint a clientId. - var clientId = config.ClientId; + var clientId = ownedConfig.ClientId; if (string.IsNullOrEmpty(clientId)) { - clientId = await _store.LoadAsync(config.Id, cancellationToken).ConfigureAwait(false); + clientId = await _store.LoadAsync(ownedConfig.Id, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(clientId)) clientId = GenerateClientId(); } - await _store.StoreAsync(config.Id, clientId!, cancellationToken).ConfigureAwait(false); + await _store.StoreAsync(ownedConfig.Id, clientId!, cancellationToken).ConfigureAwait(false); - var normalizedConfig = new HostConfig - { - Id = config.Id, - Label = config.Label, - ClientId = clientId, - InitialSubscriptions = initialSubs, - ClientConfig = config.ClientConfig, - TransportFactory = config.TransportFactory, - ReconnectPolicy = policy, - ProtocolVersions = protoVersions, - }; + var normalizedConfig = ownedConfig.Snapshot(clientId!); - var entry = new HostEntry(config.Id, normalizedConfig, clientId!); + var entry = new HostEntry(ownedConfig.Id, normalizedConfig, clientId!); // Atomic add-if-absent: TryAdd is the check-then-act done correctly, // with no separate lock and no race window. Duplicate ids surface the // typed DuplicateHostException carrying the offending id (mirrors Swift // `add` throwing `.duplicateHost(id)`). - if (!_hosts.TryAdd(config.Id.ToString(), entry)) - throw new DuplicateHostException(config.Id); + if (!_hosts.TryAdd(ownedConfig.Id.ToString(), entry)) + { + entry.Dispose(); + throw new DuplicateHostException(ownedConfig.Id); + } // Initial connect; on failure remove the host and propagate. try @@ -495,7 +589,11 @@ public async Task AddHostAsync( catch (Exception ex) { SetHostState(entry, new HostState { Kind = HostStateKind.Failed, Error = ex }); - _hosts.TryRemove(entry.Id.ToString(), out _); + if (_hosts.TryRemove(entry.Id.ToString(), out _)) + { + entry.LifetimeCts.Cancel(); + entry.Dispose(); + } throw; } @@ -560,10 +658,18 @@ public async Task RemoveHostAsync(HostId id, CancellationToken cancellationToken FinishPerHostListeners(id.ToString()); entry!.LifetimeCts.Cancel(); - var client = entry.CurrentClient; - if (client is not null) + await entry.ConnectionGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try { - try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + var client = entry.CurrentClient; + if (client is not null) + { + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + } + } + finally + { + entry.ConnectionGate.Release(); } try { await entry.SupervisorTask.ConfigureAwait(false); } catch { } try { await entry.PumpTask.ConfigureAwait(false); } catch (OperationCanceledException) { } catch { } @@ -632,10 +738,18 @@ public async Task ShutdownAsync(CancellationToken cancellationToken = default) // cancellation and exits rather than blocking on its manual-reconnect // wait forever. entry.SignalManualReconnect(); - var client = entry.CurrentClient; - if (client is not null) + await entry.ConnectionGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try { - try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + var client = entry.CurrentClient; + if (client is not null) + { + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + } + } + finally + { + entry.ConnectionGate.Release(); } } @@ -670,83 +784,215 @@ public async ValueTask DisposeAsync() private async Task OpenHostAsync(HostEntry entry, CancellationToken cancellationToken, bool isReconnect = false) { - SetHostState(entry, new HostState { Kind = HostStateKind.Connecting }); - - var transport = await entry.Config.TransportFactory!(entry.Id, cancellationToken).ConfigureAwait(false); - var client = AhpClient.Connect( - transport, - entry.Config.ClientConfig, - null); - - // On a reconnect with a known serverSeq, issue the AHP `reconnect` command - // (clientId + lastSeenServerSeq) so the host REPLAYS the actions missed - // while disconnected, instead of re-initializing from scratch. Mirrors - // Swift's HostRuntime reconnect path. Falls back to a fresh `initialize` - // on the still-live client if the host can't replay (errors / non-replay). - if (isReconnect) + CancellationTokenSource? linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + entry.LifetimeCts.Token); + var attempt = new OpenHostAttempt(); + var openTask = OpenHostCoreAsync(entry, isReconnect, attempt, linkedCts.Token); + try { - var snap = entry.Snapshot(); - ReconnectResult? reconnectResult = null; - try - { - reconnectResult = await client.ReconnectAsync( - snap.ClientId, snap.ServerSeq, entry.Config.InitialSubscriptions, cancellationToken) - .ConfigureAwait(false); - } - catch (Exception) when (!cancellationToken.IsCancellationRequested) + await openTask.WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (linkedCts.IsCancellationRequested) + { + if (!attempt.TryAbandon()) { - // Host does not support `reconnect` (or it errored) — fall through - // to a fresh `initialize` on the still-live client below. A - // cancellation (shutdown/dispose) is NOT swallowed: it propagates - // so the supervisor tears down promptly instead of blocking on a - // fallback initialize. + // Installation has committed under ConnectionGate. Let its + // synchronous replay/snapshot publication finish before the + // supervisor can launch a replacement attempt. + await openTask.ConfigureAwait(false); + return; } - if (reconnectResult?.Value is ReconnectReplayResult replay) + if (openTask.IsCompleted) { - entry.SetClient(client, snap.ProtocolVersion); - _ = ApplyReconnectReplay(entry, replay); - await SeedSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); - SetHostState(entry, new HostState { Kind = HostStateKind.Connected }); - NotifyPerHostSnapshot(entry); - NotifyPerHostSummaries(entry); - entry.PumpTask = Task.Run(() => PumpEventsAsync(entry, client)); + await openTask.ConfigureAwait(false); return; } + _ = ObserveAbandonedOpenHostAsync(openTask, linkedCts); + linkedCts = null; + throw; } + finally + { + linkedCts?.Dispose(); + } + } + + private async Task OpenHostCoreAsync( + HostEntry entry, + bool isReconnect, + OpenHostAttempt attempt, + CancellationToken cancellationToken) + { + SetHostState(entry, new HostState { Kind = HostStateKind.Connecting }); - InitializeResult result; + var transport = await entry.Config.TransportFactory(entry.Id, cancellationToken).ConfigureAwait(false); + AhpClient? client = null; + var installed = false; try { - result = await client.InitializeAsync( + cancellationToken.ThrowIfCancellationRequested(); + client = AhpClient.Connect( + transport, + entry.Config.ClientConfig, + null); + transport = null; + // Register before the first handshake request so notifications that + // race initialize/reconnect are buffered rather than discarded. + var stream = client.CreateEventStream(); + + // On a reconnect with a known serverSeq, issue the AHP `reconnect` command + // (clientId + lastSeenServerSeq) so the host REPLAYS the actions missed + // while disconnected, instead of re-initializing from scratch. Mirrors + // Swift's HostRuntime reconnect path. Falls back to a fresh `initialize` + // on the still-live client if the host rejects reconnect. + var subscriptions = entry.Config.InitialSubscriptions; + if (isReconnect) + { + var snap = entry.Snapshot(); + subscriptions = snap.Subscriptions; + ReconnectResult? reconnectResult = null; + try + { + reconnectResult = await client.ReconnectAsync( + snap.ClientId, snap.ServerSeq, subscriptions, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Host does not support `reconnect` (or it errored) — fall through + // to a fresh `initialize` on the still-live client below. A + // cancellation (shutdown/dispose) is NOT swallowed: it propagates + // so the supervisor tears down promptly instead of blocking on a + // fallback initialize. + } + + if (reconnectResult?.Value is ReconnectReplayResult replay) + { + var summaries = await FetchSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await entry.ConnectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + attempt.BeginCommit(cancellationToken); + InstallOpenHost(entry, client, snap.ProtocolVersion, stream, + () => + { + if (summaries is not null) entry.SeedSessionSummaries(summaries); + ApplyReconnectReplay(entry, replay); + }); + installed = true; + } + finally + { + entry.ConnectionGate.Release(); + } + return; + } + + if (reconnectResult?.Value is ReconnectSnapshotResult snapshot) + { + var summaries = await FetchSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await entry.ConnectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + attempt.BeginCommit(cancellationToken); + InstallOpenHost(entry, client, snap.ProtocolVersion, stream, + () => + { + if (summaries is not null) entry.SeedSessionSummaries(summaries); + ApplyReconnectSnapshot(entry, snapshot); + }); + installed = true; + } + finally + { + entry.ConnectionGate.Release(); + } + return; + } + } + + var result = await client.InitializeAsync( entry.ClientId, entry.Config.ProtocolVersions, - entry.Config.InitialSubscriptions, + subscriptions, cancellationToken) .ConfigureAwait(false); + + // Extract the root-state snapshot (agents + activeSessions) that the + // server returned for the root channel, mirroring the + // `init1.snapshots.first(where: resource == RootResourceURI)` block in + // Swift's completeHandshake. + var root = ExtractRootSnapshot(result.Snapshots); + var initialSummaries = await FetchSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await entry.ConnectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + attempt.BeginCommit(cancellationToken); + InstallOpenHost(entry, client, result.ProtocolVersion, stream, + () => + { + if (initialSummaries is not null) entry.SeedSessionSummaries(initialSummaries); + entry.ApplyConnected(root, result.ServerSeq); + }); + installed = true; + } + finally + { + entry.ConnectionGate.Release(); + } } - catch + finally { - try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } - throw; + if (!installed) + { + if (client is not null) + { + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + } + else if (transport is not null) + { + try { await transport.CloseAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + try { await transport.DisposeAsync().ConfigureAwait(false); } catch { } + } + } } + } - entry.SetClient(client, result.ProtocolVersion); - - // Extract the root-state snapshot (agents + activeSessions) that the - // server returned for the root channel, mirroring the - // `init1.snapshots.first(where: resource == RootResourceURI)` block in - // Swift's completeHandshake. - var root = ExtractRootSnapshot(result); - var generation = entry.ApplyConnected(root, result.ServerSeq); + private static async Task ObserveAbandonedOpenHostAsync( + Task openTask, + CancellationTokenSource linkedCts) + { + try { await openTask.ConfigureAwait(false); } + catch { } + finally { linkedCts.Dispose(); } + } - // Opportunistic `listSessions` seed. Cheap on first connect; kept in - // sync by notifications afterward. Non-fatal: a host that doesn't - // answer (or is slow) leaves the cache untouched, exactly like Swift's - // `try? await client.request("listSessions", ...)`. We bound the wait - // with a short timeout so hosts that never answer don't stall the - // connect (the default request timeout is 30s). - await SeedSessionSummariesAsync(entry, client, cancellationToken).ConfigureAwait(false); + private void InstallOpenHost( + HostEntry entry, + AhpClient client, + string protocolVersion, + EventStream stream, + Action applyHandshake) + { + entry.SetClient(client, protocolVersion); + try + { + applyHandshake(); + CompleteOpenHost(entry, stream); + } + catch + { + entry.SetClient(null, ""); + throw; + } + } + private void CompleteOpenHost(HostEntry entry, EventStream stream) + { SetHostState(entry, new HostState { Kind = HostStateKind.Connected }); // Emit a post-connect snapshot + summary list to per-host stream @@ -755,10 +1001,9 @@ private async Task OpenHostAsync(HostEntry entry, CancellationToken cancellation // Swift's hostSnapshots / sessionSummaries watchers. NotifyPerHostSnapshot(entry); NotifyPerHostSummaries(entry); - _ = generation; // bumped for parity; surfaced via HostHandle.Generation // Fan events out to subscribers. - entry.PumpTask = Task.Run(() => PumpEventsAsync(entry, client)); + entry.PumpTask = Task.Run(() => PumpEventsAsync(entry, stream)); } /// @@ -767,14 +1012,25 @@ private async Task OpenHostAsync(HostEntry entry, CancellationToken cancellation /// every replayed action out exactly like the live pump (host-state mirror + /// global subscription fan-in + per-(host,uri) listeners) so consumers that /// subscribed before the drop observe the actions missed while disconnected. - /// Missing URIs are left for the next subscribe cycle. + /// URIs in Missing are pruned from the reconnect subscription set. /// private ulong ApplyReconnectReplay(HostEntry entry, ReconnectReplayResult replay) { - var lastSeq = entry.Snapshot().ServerSeq; + var initialSeq = entry.Snapshot().ServerSeq; + var previousSeq = initialSeq; if (replay.Actions is { } seqScan) - foreach (var env in seqScan) if (env.ServerSeq > lastSeq) lastSeq = env.ServerSeq; - var generation = entry.ApplyConnected(null, lastSeq); + { + foreach (var env in seqScan) + { + if (env.ServerSeq <= previousSeq) + throw new AhpTransportException( + "protocol", + $"ahp: reconnect replay sequence {env.ServerSeq} did not advance past {previousSeq}"); + previousSeq = env.ServerSeq; + } + } + + var generation = entry.ApplyConnected(null, initialSeq); if (replay.Actions is { } actions) { @@ -782,6 +1038,9 @@ private ulong ApplyReconnectReplay(HostEntry entry, ReconnectReplayResult replay { var evt = new SubscriptionEventAction(env); ApplyEventToHostState(entry, evt); + if (env.Channel == ProtocolVersion.RootResourceUri) + entry.ApplyRootAction(env.Action); + entry.AdvanceServerSeq(env.ServerSeq); var hostEv = new HostSubscriptionEvent(entry.Id, env.Channel, evt); List>? channels; lock (_subsLock) @@ -795,53 +1054,95 @@ private ulong ApplyReconnectReplay(HostEntry entry, ReconnectReplayResult replay BroadcastPerResourceEvent(entry.Id, env.Channel, evt); } } + entry.RemoveSubscriptions(replay.Missing); return generation; } /// - /// Pulls the out of the root-channel snapshot in an - /// , or null if no root snapshot is present. + /// Applies a reconnect snapshot without issuing a second initialize. The + /// every returned resource snapshot is published before the host transitions + /// to connected. The subscription set is retained because stateless + /// subscriptions intentionally have no snapshot. /// - private static RootState? ExtractRootSnapshot(InitializeResult result) + private ulong ApplyReconnectSnapshot(HostEntry entry, ReconnectSnapshotResult result) { - if (result.Snapshots is null) return null; - foreach (var snap in result.Snapshots) + var lastSeq = entry.Snapshot().ServerSeq; + foreach (var snapshot in result.Snapshots) { - if (snap.Resource == ProtocolVersion.RootResourceUri && snap.State?.Root is { } root) + if (snapshot.FromSeq > lastSeq) lastSeq = snapshot.FromSeq; + } + var generation = entry.ApplyConnected(ExtractRootSnapshot(result.Snapshots), lastSeq); + foreach (var snapshot in result.Snapshots) + { + var evt = new SubscriptionEventSnapshot(snapshot); + var hostEv = new HostSubscriptionEvent(entry.Id, snapshot.Resource, evt); + List>? channels; + lock (_subsLock) + { + channels = _subChannels.Count == 0 + ? null + : new List>(_subChannels); + } + if (channels is not null) + foreach (var ch in channels) ch.Writer.TryWrite(hostEv); + BroadcastPerResourceEvent(entry.Id, snapshot.Resource, evt); + } + return generation; + } + + /// Pulls root state out of a snapshot collection, if present. + private static RootState? ExtractRootSnapshot(IReadOnlyList? snapshots) + { + if (snapshots is null) return null; + foreach (var snapshot in snapshots) + { + if (snapshot.Resource == ProtocolVersion.RootResourceUri && snapshot.State?.Root is { } root) return root; } return null; } /// - /// Issues a best-effort listSessions on the root channel and seeds the - /// host's summary cache. Bounded by a short timeout and fully non-fatal — - /// failures/timeouts leave the cache as-is. + /// Issues a best-effort listSessions on the root channel. The caller + /// applies the returned summaries only after the connection attempt commits. + /// Bounded by a short timeout and fully non-fatal. /// - private static async Task SeedSessionSummariesAsync(HostEntry entry, AhpClient client, CancellationToken cancellationToken) + private static async Task?> FetchSessionSummariesAsync( + HostEntry entry, + AhpClient client, + CancellationToken cancellationToken) { try { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(750)); + using var timeout = TimeProviderCompatibility.CreateCancellationTokenSource( + entry.Config.ClientConfig!.TimeProvider, + TimeSpan.FromMilliseconds(750)); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeout.Token); var listed = await client.RequestAsync( "listSessions", new ListSessionsParams { Channel = ProtocolVersion.RootResourceUri }, timeoutCts.Token) .ConfigureAwait(false); - if (listed?.Items is { } items) - entry.SeedSessionSummaries(items); + return listed?.Items is { } items + ? new List(items) + : null; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch { // Non-fatal: host did not answer listSessions in time, or returned // an error. Cache stays as-is (matches Swift `try?`). + return null; } } - private async Task PumpEventsAsync(HostEntry entry, AhpClient client) + private async Task PumpEventsAsync(HostEntry entry, EventStream stream) { - var stream = client.CreateEventStream(); try { // Pass the lifetime token so a shutdown/removal (LifetimeCts.Cancel) @@ -855,6 +1156,11 @@ private async Task PumpEventsAsync(HostEntry entry, AhpClient client) // observer reading the next snapshot sees the post-event state // (mirrors the ordering in Swift HostRuntime.handleEvent). var summaryTouched = ApplyEventToHostState(entry, ev.Event); + var rootTouched = ev.Event is SubscriptionEventAction rootAction + && rootAction.Envelope.Channel == ProtocolVersion.RootResourceUri + && entry.ApplyRootAction(rootAction.Envelope.Action); + if (ev.Event is SubscriptionEventAction action) + entry.AdvanceServerSeq(action.Envelope.ServerSeq); var hostEv = new HostSubscriptionEvent(entry.Id, ev.Channel, ev.Event); List>? channels; @@ -874,9 +1180,12 @@ private async Task PumpEventsAsync(HostEntry entry, AhpClient client) // A session-summary-shaped notification advanced the cache: // re-yield the snapshot + summary list to per-host listeners. - if (summaryTouched) + if (summaryTouched || rootTouched) { NotifyPerHostSnapshot(entry); + } + if (summaryTouched) + { NotifyPerHostSummaries(entry); } } @@ -925,9 +1234,32 @@ private async Task SuperviseAsync(HostEntry entry) if (ct.IsCancellationRequested) return; // Tear the old client down before reconnecting (whether it dropped - // or we're forcing a manual reconnect). - try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } - entry.SetClient(null, ""); + // or we're forcing a manual reconnect). Serialize replacement with + // subscribe/unsubscribe, then drain the old event pump so reconnect + // snapshots the final sequence observed on that connection. + try + { + await entry.ConnectionGate.WaitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + try + { + var oldPump = entry.PumpTask; + BeginReconnect(entry, new HostState + { + Kind = HostStateKind.Reconnecting, + Attempt = 1, + }); + try { await client.ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { } + try { await oldPump.ConfigureAwait(false); } catch (OperationCanceledException) { } catch { } + } + finally + { + entry.ConnectionGate.Release(); + } // A manual reconnect bypasses the reconnect policy entirely — even a // `.disabled` policy reconnects on explicit request. A spontaneous @@ -952,12 +1284,25 @@ private async Task SuperviseAsync(HostEntry entry) while (true) { if (ct.IsCancellationRequested) return; - SetHostState(entry, new HostState { Kind = HostStateKind.Reconnecting, Attempt = attempt }); + Task? backoffTask = null; if (!immediate) { var delay = policy.BackoffFor(attempt); - try { await Task.Delay(delay, ct).ConfigureAwait(false); } + backoffTask = TimeProviderCompatibility.DelayAsync( + entry.Config.ClientConfig!.TimeProvider, + delay, + ct); + } + + // Publish Reconnecting only after the backoff timer is armed. Tests + // and observers can then advance a fake TimeProvider without racing + // timer registration. + SetHostState(entry, new HostState { Kind = HostStateKind.Reconnecting, Attempt = attempt }); + + if (backoffTask is not null) + { + try { await backoffTask.ConfigureAwait(false); } catch (OperationCanceledException) { return; } } immediate = false; @@ -1031,7 +1376,17 @@ private static async Task ParkUntilManualReconnectAsync(HostEntry entry, C private void SetHostState(HostEntry entry, HostState state) { entry.SetState(state); + PublishHostState(entry, state); + } + + private void BeginReconnect(HostEntry entry, HostState state) + { + entry.BeginReconnect(state); + PublishHostState(entry, state); + } + private void PublishHostState(HostEntry entry, HostState state) + { BroadcastHostEvent(new HostEvent(entry.Id, state)); // A state transition is an observable change for hostSnapshots @@ -1426,12 +1781,11 @@ public async Task SubscribeAsync( Guard.ThrowIfNull(uri, nameof(uri)); if (!_hosts.TryGetValue(host.ToString(), out var entry)) throw new UnknownHostException(host); - var client = entry.CurrentClient; - if (client is null) - throw new HostNotConnectedException(host); - var sub = client.AttachSubscription(uri); - try + + while (true) { + var client = await entry.WaitForClientAsync(cancellationToken).ConfigureAwait(false); + // Issue the subscribe RPC; track the URI for replay on success. The // protocol mandates a result; a null result is a protocol violation // surfaced loudly rather than returned as null. @@ -1440,21 +1794,29 @@ public async Task SubscribeAsync( new SubscribeParams { Channel = uri }, cancellationToken).ConfigureAwait(false) ?? throw new AhpRpcException(JsonRpcErrorCodes.InternalError, "ahp: subscribe returned no result"); - entry.AppendSubscription(uri); - return result; - } - catch - { - sub.Dispose(); - throw; + + await entry.ConnectionGate.WaitAsync(entry.LifetimeCts.Token).ConfigureAwait(false); + try + { + if (ReferenceEquals(client, entry.CurrentClient)) + { + entry.AppendSubscription(uri); + return result; + } + } + finally + { + entry.ConnectionGate.Release(); + } + // The connection changed after the old host acknowledged. Repeat the + // RPC on the replacement before reporting success. } } /// /// Unsubscribes from on , sending - /// the unsubscribe notification, closing the host client's local - /// subscriptions for the URI, and dropping the URI from the replay set so it is - /// no longer re-subscribed across reconnects. Throws + /// the unsubscribe notification and dropping the URI from the replay set + /// so it is no longer re-subscribed across reconnects. Throws /// if no such host is registered, or /// if the host has no live connection. /// Mirrors Swift's unsubscribe(host:uri:). @@ -1478,15 +1840,31 @@ public async Task UnsubscribeAsync( Guard.ThrowIfNull(uri, nameof(uri)); if (!_hosts.TryGetValue(host.ToString(), out var entry)) throw new UnknownHostException(host); - var client = entry.CurrentClient; - if (client is null) - throw new HostNotConnectedException(host); - // Send the unsubscribe RPC + close the host client's local per-URI - // subscriptions, then forget the URI for replay. Order matches Swift's - // handleUnsubscribe (RPC first, then removeSubscription). - await client.UnsubscribeAsync(uri, cancellationToken).ConfigureAwait(false); - entry.RemoveSubscription(uri); + while (true) + { + var client = await entry.WaitForClientAsync(cancellationToken).ConfigureAwait(false); + + // Send the unsubscribe RPC, then forget the URI for replay. Order + // matches Swift's handleUnsubscribe (RPC first, then removeSubscription). + await client.UnsubscribeAsync(uri, cancellationToken).ConfigureAwait(false); + + await entry.ConnectionGate.WaitAsync(entry.LifetimeCts.Token).ConfigureAwait(false); + try + { + if (ReferenceEquals(client, entry.CurrentClient)) + { + entry.RemoveSubscription(uri); + return; + } + } + finally + { + entry.ConnectionGate.Release(); + } + // The connection changed after the old host acknowledged. Repeat the + // notification on the replacement before reporting success. + } } /// @@ -1506,4 +1884,22 @@ public PerResourceListener(string uri, Channel channel) Uri = uri; Channel = channel; } } + + internal sealed class OpenHostAttempt + { + private const int Active = 0; + private const int Committing = 1; + private const int Abandoned = 2; + private int _state; + + public bool TryAbandon() => + Interlocked.CompareExchange(ref _state, Abandoned, Active) == Active; + + public void BeginCommit(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Interlocked.CompareExchange(ref _state, Committing, Active) != Active) + throw new OperationCanceledException(cancellationToken); + } + } } diff --git a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs index 6ee702c12..c65378fcb 100644 --- a/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs +++ b/clients/dotnet/src/AgentHostProtocol/Hosts/MultiHostStateMirror.cs @@ -18,14 +18,14 @@ public sealed class MultiHostStateMirror // The per-resource maps key by HostedResourceKey (host + URI value type) so a // host id and a URI compose into one collision-free key with value equality — // no ad-hoc tuple delimiter to confuse with reserved URI characters. - private readonly ConcurrentDictionary _roots = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _roots = new(); private readonly ConcurrentDictionary _sessions = new(); private readonly ConcurrentDictionary _chats = new(); private readonly ConcurrentDictionary _terminals = new(); private readonly ConcurrentDictionary _changesets = new(); /// Stores for . - public void PutRoot(string hostId, RootState root) + public void PutRoot(HostId hostId, RootState root) { Guard.ThrowIfNull(hostId, nameof(hostId)); Guard.ThrowIfNull(root, nameof(root)); @@ -33,11 +33,11 @@ public void PutRoot(string hostId, RootState root) } /// Returns the root snapshot for , or (default, false) if absent. - public (RootState? Value, bool Found) Root(string hostId) => + public (RootState? Value, bool Found) Root(HostId hostId) => _roots.TryGetValue(hostId, out var v) ? (v, true) : (default, false); /// Stores a session snapshot under (hostId, uri). - public void PutSession(string hostId, string uri, SessionState state) + public void PutSession(HostId hostId, string uri, SessionState state) { Guard.ThrowIfNull(hostId, nameof(hostId)); Guard.ThrowIfNull(uri, nameof(uri)); @@ -46,11 +46,11 @@ public void PutSession(string hostId, string uri, SessionState state) } /// Returns the session snapshot at (hostId, uri), or (default, false) if absent. - public (SessionState? Value, bool Found) Session(string hostId, string uri) => + public (SessionState? Value, bool Found) Session(HostId hostId, string uri) => _sessions.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); /// Stores a chat snapshot under (hostId, uri). - public void PutChat(string hostId, string uri, ChatState state) + public void PutChat(HostId hostId, string uri, ChatState state) { Guard.ThrowIfNull(hostId, nameof(hostId)); Guard.ThrowIfNull(uri, nameof(uri)); @@ -59,11 +59,11 @@ public void PutChat(string hostId, string uri, ChatState state) } /// Returns the chat snapshot at (hostId, uri), or (default, false) if absent. - public (ChatState? Value, bool Found) Chat(string hostId, string uri) => + public (ChatState? Value, bool Found) Chat(HostId hostId, string uri) => _chats.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); /// Stores a terminal snapshot under (hostId, uri). - public void PutTerminal(string hostId, string uri, TerminalState state) + public void PutTerminal(HostId hostId, string uri, TerminalState state) { Guard.ThrowIfNull(hostId, nameof(hostId)); Guard.ThrowIfNull(uri, nameof(uri)); @@ -72,11 +72,11 @@ public void PutTerminal(string hostId, string uri, TerminalState state) } /// Returns the terminal snapshot at (hostId, uri), or (default, false) if absent. - public (TerminalState? Value, bool Found) Terminal(string hostId, string uri) => + public (TerminalState? Value, bool Found) Terminal(HostId hostId, string uri) => _terminals.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); /// Stores a changeset snapshot under (hostId, uri). - public void PutChangeset(string hostId, string uri, ChangesetState state) + public void PutChangeset(HostId hostId, string uri, ChangesetState state) { Guard.ThrowIfNull(hostId, nameof(hostId)); Guard.ThrowIfNull(uri, nameof(uri)); @@ -85,21 +85,21 @@ public void PutChangeset(string hostId, string uri, ChangesetState state) } /// Returns the changeset snapshot at (hostId, uri), or (default, false) if absent. - public (ChangesetState? Value, bool Found) Changeset(string hostId, string uri) => + public (ChangesetState? Value, bool Found) Changeset(HostId hostId, string uri) => _changesets.TryGetValue(new HostedResourceKey(hostId, uri), out var v) ? (v, true) : (default, false); /// Removes every snapshot belonging to . - public void DropHost(string hostId) + public void DropHost(HostId hostId) { _roots.TryRemove(hostId, out _); - foreach (var k in _sessions.Keys) if (k.HostId.ToString() == hostId) _sessions.TryRemove(k, out _); - foreach (var k in _chats.Keys) if (k.HostId.ToString() == hostId) _chats.TryRemove(k, out _); - foreach (var k in _terminals.Keys) if (k.HostId.ToString() == hostId) _terminals.TryRemove(k, out _); - foreach (var k in _changesets.Keys) if (k.HostId.ToString() == hostId) _changesets.TryRemove(k, out _); + foreach (var k in _sessions.Keys) if (k.HostId.Equals(hostId)) _sessions.TryRemove(k, out _); + foreach (var k in _chats.Keys) if (k.HostId.Equals(hostId)) _chats.TryRemove(k, out _); + foreach (var k in _terminals.Keys) if (k.HostId.Equals(hostId)) _terminals.TryRemove(k, out _); + foreach (var k in _changesets.Keys) if (k.HostId.Equals(hostId)) _changesets.TryRemove(k, out _); } /// Removes the snapshot at (hostId, uri) across every resource kind. - public void DropResource(string hostId, string uri) + public void DropResource(HostId hostId, string uri) { var key = new HostedResourceKey(hostId, uri); _sessions.TryRemove(key, out _); diff --git a/clients/dotnet/src/AgentHostProtocol/Reducers.cs b/clients/dotnet/src/AgentHostProtocol/Reducers.cs index 40e64388a..d67ab415b 100644 --- a/clients/dotnet/src/AgentHostProtocol/Reducers.cs +++ b/clients/dotnet/src/AgentHostProtocol/Reducers.cs @@ -4,10 +4,7 @@ #nullable enable using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; using System.Text.Json; namespace Microsoft.AgentHostProtocol; @@ -2479,11 +2476,6 @@ public static ReduceOutcome ApplyToAutomationRun( /// reads its type field directly. Mirrors the Swift client's /// isClientDispatchable. /// - // Reads the variant's `Type` property reflectively + the [WireValue] attributes - // on ActionType — trim/AOT-relevant, so the declaration is kept; the cost is one - // cached property read, not a full serialize of the action's nested payload. - [RequiresUnreferencedCode("Reflects over the action variant's Type property and ActionType's [WireValue] members; trimming may remove the metadata it reads. Declared (not suppressed) so trim/AOT consumers are warned at the call site.")] - [RequiresDynamicCode("Reflects over the action variant's Type property and ActionType's [WireValue] members.")] public static bool IsClientDispatchable(StateAction action) { Guard.ThrowIfNull(action, nameof(action)); @@ -2503,45 +2495,8 @@ public static bool IsClientDispatchable(StateAction action) default: // Known variant record: read its ActionType discriminator and map it // to the wire string the serializer would have emitted. - if (TryReadActionType(inner, out var actionType) - && s_actionTypeWire.TryGetValue(actionType, out var wire)) - { - return s_clientDispatchableActions.Contains(wire); - } - return false; - } - } - - // Cache: variant CLR type -> its `Type` (ActionType) property accessor. Every - // generated state-action variant carries `public ActionType Type { get; init; }`. - private static readonly ConcurrentDictionary s_typeProperty = new(); - - // ActionType -> wire string, derived once from the [WireValue] attributes (the - // same source the WireEnumConverter uses), so the lookup needs no serialize. - private static readonly Dictionary s_actionTypeWire = BuildActionTypeWireMap(); - - [UnconditionalSuppressMessage("Trimming", "IL2070", - Justification = "GetProperty(\"Type\") over a state-action variant CLR type; the variants are all preserved generated records with a public ActionType Type property.")] - private static bool TryReadActionType(object variant, out ActionType actionType) - { - var prop = s_typeProperty.GetOrAdd(variant.GetType(), static t => t.GetProperty("Type")); - if (prop is not null && prop.GetValue(variant) is ActionType at) - { - actionType = at; - return true; - } - actionType = default; - return false; - } - - private static Dictionary BuildActionTypeWireMap() - { - var map = new Dictionary(); - foreach (FieldInfo field in typeof(ActionType).GetFields(BindingFlags.Public | BindingFlags.Static)) - { - var value = (ActionType)field.GetValue(null)!; - map[value] = field.GetCustomAttribute()?.Value ?? field.Name; + return GeneratedActionMetadata.TryGetActionType(inner, out var actionType) + && s_clientDispatchableActions.Contains(GeneratedActionMetadata.GetWireName(actionType)); } - return map; } } diff --git a/clients/dotnet/src/AgentHostProtocol/Subscription.cs b/clients/dotnet/src/AgentHostProtocol/Subscription.cs index 8cc3d944f..684321c77 100644 --- a/clients/dotnet/src/AgentHostProtocol/Subscription.cs +++ b/clients/dotnet/src/AgentHostProtocol/Subscription.cs @@ -30,6 +30,17 @@ public sealed class SubscriptionEventAction : SubscriptionEvent public SubscriptionEventAction(ActionEnvelope envelope) => Envelope = envelope; } +/// A point-in-time snapshot delivered when reconnect replaces replay with fresh state. +public sealed class SubscriptionEventSnapshot : SubscriptionEvent +{ + /// The resource snapshot from the server. + public Snapshot Snapshot { get; } + + /// Creates a new snapshot event. + public SubscriptionEventSnapshot(Snapshot snapshot) => + Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); +} + /// Mirrors the root/sessionAdded notification. public sealed class SubscriptionEventSessionAdded : SubscriptionEvent { diff --git a/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs b/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs index 92186fc67..1e5fcf7af 100644 --- a/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs +++ b/clients/dotnet/src/AgentHostProtocol/SystemTextJsonAhpSerializer.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace Microsoft.AgentHostProtocol; @@ -16,29 +17,34 @@ namespace Microsoft.AgentHostProtocol; /// public static class AhpJson { - /// The canonical serializer options used by the default serializer. - public static readonly JsonSerializerOptions Options = new() - { - // Most wire names are camelCase(PropertyName); generated types carry an - // explicit [JsonPropertyName] only where they aren't. - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - // Optional fields opt into omission per-property via - // [JsonIgnore(WhenWritingNull)]; the global default stays Never so - // required fields still serialize their null/zero values. - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, - }; - - static AhpJson() + /// The canonical, read-only serializer options used by the default serializer. + public static readonly JsonSerializerOptions Options = CreateOptions(); + + internal static JsonSerializerOptions CreateOptions(JsonSerializerOptions? source = null) { - // Freeze the shared options so consumer mutation fails fast rather than - // poisoning the global wire config. IL2026/IL3050: populateMissingResolver - // wires the reflection-based default resolver (this library targets - // reflection-based STJ until a JsonSerializerContext lands, per - // docs/decisions/serialization.md). -#pragma warning disable IL2026, IL3050 - Options.MakeReadOnly(populateMissingResolver: true); -#pragma warning restore IL2026, IL3050 + JsonSerializerOptions options = source is null + ? new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + } + : new JsonSerializerOptions(source); + + options.TypeInfoResolverChain.Insert(0, AhpJsonMetadata.Default); + if (CreateReflectionFallback() is { } reflectionResolver) + { + options.TypeInfoResolverChain.Add(reflectionResolver); + } + options.MakeReadOnly(); + return options; } + + [UnconditionalSuppressMessage("Trimming", "IL2026", + Justification = "The reflection resolver is unreachable when System.Text.Json reflection is disabled; Native AOT substitutes IsReflectionEnabledByDefault with false.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "The reflection resolver is unreachable when System.Text.Json reflection is disabled; Native AOT substitutes IsReflectionEnabledByDefault with false.")] + private static DefaultJsonTypeInfoResolver? CreateReflectionFallback() => + JsonSerializer.IsReflectionEnabledByDefault ? new DefaultJsonTypeInfoResolver() : null; } /// @@ -52,73 +58,62 @@ public sealed class SystemTextJsonAhpSerializer : IAhpSerializer private readonly JsonSerializerOptions _options; /// Creates the serializer. - /// Override options; defaults to . + /// + /// Override options; defaults to . Custom options + /// are copied, extended with the generated AHP metadata, and frozen so later + /// caller mutation cannot change serializer behavior while requests are in + /// flight. Add a custom + /// to serialize non-AHP types when reflection is disabled. + /// public SystemTextJsonAhpSerializer(JsonSerializerOptions? options = null) { - _options = options ?? AhpJson.Options; + if (options is null) + { + _options = AhpJson.Options; + return; + } + + _options = AhpJson.CreateOptions(options); } /// A shared, reusable instance using the default options. public static SystemTextJsonAhpSerializer Default { get; } = new(); - // This serializer is the reflection-based System.Text.Json path (source-gen - // deferred per docs/decisions/serialization.md), so every (de)serialize entry - // point is genuinely trim/AOT-unsafe: STJ may need types that cannot be - // statically analyzed (under trimming) or runtime code generation (under - // Native AOT). The reflection unsafety is declared on the contract via these - // attributes, matching the same attributes on the IAhpSerializer interface — - // the honest interim state until a JsonSerializerContext lands. (The messages - // mirror IAhpSerializer's SerializerTrimWarnings; the trim analyzer only - // requires the attribute to be PRESENT on both, not message-identical, and - // that constant is internal to the Abstractions assembly.) - private const string TrimUnreferencedCode = - "JSON (de)serialization here is reflection-based and may reference types that cannot be statically analyzed when trimming. Provide a JsonSerializerContext or preserve the wire types."; - private const string TrimDynamicCode = - "JSON (de)serialization here is reflection-based and may require runtime code generation under Native AOT. Use System.Text.Json source generation for AOT."; - /// - [RequiresUnreferencedCode(TrimUnreferencedCode)] - [RequiresDynamicCode(TrimDynamicCode)] - public string Serialize(T value) => JsonSerializer.Serialize(value, _options); + public string Serialize(T value) => JsonSerializer.Serialize(value, GetTypeInfo()); /// - [RequiresUnreferencedCode(TrimUnreferencedCode)] - [RequiresDynamicCode(TrimDynamicCode)] public JsonElement SerializeToElement(T value) => - JsonSerializer.SerializeToElement(value, _options); + JsonSerializer.SerializeToElement(value, GetTypeInfo()); /// - [RequiresUnreferencedCode(TrimUnreferencedCode)] - [RequiresDynamicCode(TrimDynamicCode)] public T Deserialize(string json) => - JsonSerializer.Deserialize(json, _options) + JsonSerializer.Deserialize(json, GetTypeInfo()) ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); /// - [RequiresUnreferencedCode(TrimUnreferencedCode)] - [RequiresDynamicCode(TrimDynamicCode)] public T Deserialize(ReadOnlySpan utf8Json) => - JsonSerializer.Deserialize(utf8Json, _options) + JsonSerializer.Deserialize(utf8Json, GetTypeInfo()) ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); /// - [RequiresUnreferencedCode(TrimUnreferencedCode)] - [RequiresDynamicCode(TrimDynamicCode)] public T Deserialize(JsonElement element) => - element.Deserialize(_options) + JsonSerializer.Deserialize(element, GetTypeInfo()) ?? throw new JsonException($"Deserialized null for {typeof(T).Name}"); /// - [RequiresUnreferencedCode(TrimUnreferencedCode)] - [RequiresDynamicCode(TrimDynamicCode)] public JsonRpcMessage DecodeMessage(TransportMessage message) => message.Frame == TransportFrame.Text ? Deserialize(message.Text ?? string.Empty) : Deserialize(message.Binary.Span); /// - [RequiresUnreferencedCode(TrimUnreferencedCode)] - [RequiresDynamicCode(TrimDynamicCode)] public TransportMessage EncodeMessage(JsonRpcMessage message) => TransportMessage.FromText(Serialize(message)); + + private JsonTypeInfo GetTypeInfo() => + _options.GetTypeInfo(typeof(T)) as JsonTypeInfo + ?? throw new NotSupportedException( + $"No JSON metadata is registered for {typeof(T)}. " + + $"Add a JsonSerializerContext for custom types to {nameof(JsonSerializerOptions.TypeInfoResolverChain)}."); } diff --git a/clients/dotnet/src/AgentHostProtocol/TimeProviderCompatibility.cs b/clients/dotnet/src/AgentHostProtocol/TimeProviderCompatibility.cs new file mode 100644 index 000000000..7357a2023 --- /dev/null +++ b/clients/dotnet/src/AgentHostProtocol/TimeProviderCompatibility.cs @@ -0,0 +1,33 @@ +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AgentHostProtocol; + +internal static class TimeProviderCompatibility +{ + public static Task DelayAsync( + TimeProvider timeProvider, + TimeSpan delay, + CancellationToken cancellationToken) + { +#if NET8_0_OR_GREATER + return Task.Delay(delay, timeProvider, cancellationToken); +#else + return timeProvider.Delay(delay, cancellationToken); +#endif + } + + public static CancellationTokenSource CreateCancellationTokenSource( + TimeProvider timeProvider, + TimeSpan delay) + { +#if NET8_0_OR_GREATER + return new CancellationTokenSource(delay, timeProvider); +#else + return timeProvider.CreateCancellationTokenSource(delay); +#endif + } +} diff --git a/clients/dotnet/src/AgentHostProtocol/WebSocketTransport.cs b/clients/dotnet/src/AgentHostProtocol/WebSocketTransport.cs index aaf639387..64f6474ba 100644 --- a/clients/dotnet/src/AgentHostProtocol/WebSocketTransport.cs +++ b/clients/dotnet/src/AgentHostProtocol/WebSocketTransport.cs @@ -40,15 +40,17 @@ public sealed class WebSocketTransportOptions /// public sealed class WebSocketTransport : ITransport { - private readonly ClientWebSocket _ws; + private readonly WebSocket _ws; private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly long _maxMessageBytes; private int _disposed; + internal long MaxMessageBytes => _maxMessageBytes; + // Receive buffer: 64 KiB initial, grows as needed. private byte[] _receiveBuffer = new byte[64 * 1024]; - private WebSocketTransport(ClientWebSocket ws, long maxMessageBytes) + internal WebSocketTransport(WebSocket ws, long maxMessageBytes) { _ws = ws; _maxMessageBytes = maxMessageBytes; @@ -68,18 +70,32 @@ public static async Task ConnectAsync( WebSocketTransportOptions? options = null, CancellationToken cancellationToken = default) { + return await ConnectCoreAsync( + uri, + options, + static (ws, target, ct) => ws.ConnectAsync(target, ct), + cancellationToken).ConfigureAwait(false); + } + + internal static async Task ConnectCoreAsync( + Uri uri, + WebSocketTransportOptions? options, + Func connectAsync, + CancellationToken cancellationToken) + { + var configureSocket = options?.ConfigureSocket; + var maxBytes = options?.MaxMessageBytes ?? (32L * 1024 * 1024); var ws = new ClientWebSocket(); try { - options?.ConfigureSocket?.Invoke(ws); - await ws.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); + configureSocket?.Invoke(ws); + await connectAsync(ws, uri, cancellationToken).ConfigureAwait(false); } catch { ws.Dispose(); throw; } - var maxBytes = options?.MaxMessageBytes ?? (32L * 1024 * 1024); return new WebSocketTransport(ws, maxBytes); } diff --git a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj new file mode 100644 index 000000000..15c7dfedc --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + Exe + true + true + true + false + true + false + true + false + + + + + + + diff --git a/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs new file mode 100644 index 000000000..59fc7660e --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.AotSmoke/Program.cs @@ -0,0 +1,126 @@ +using Microsoft.AgentHostProtocol; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +var serializer = SystemTextJsonAhpSerializer.Default; + +var initialize = new InitializeParams +{ + Channel = "ahp-root://", + ClientId = "native-aot-smoke", + ProtocolVersions = new List { "0.1.0" }, + InitialSubscriptions = new List { "ahp-root://" }, +}; + +var initializeJson = serializer.Serialize(initialize); +var initializeRoundTrip = serializer.Deserialize(initializeJson); +Require(initializeRoundTrip.ClientId == initialize.ClientId, "InitializeParams round trip failed."); + +var request = new JsonRpcMessage +{ + Request = new JsonRpcRequest + { + Id = 1, + Method = "initialize", + Params = serializer.SerializeToElement(initialize), + }, +}; +var decodedRequest = serializer.DecodeMessage(serializer.EncodeMessage(request)); +Require(decodedRequest.Request?.Method == "initialize", "JSON-RPC framing round trip failed."); + +var action = new StateAction( + new SessionIsReadChangedAction + { + Type = ActionType.SessionIsReadChanged, + IsRead = true, + }); +var actionJson = serializer.Serialize(action); +var actionRoundTrip = serializer.Deserialize(actionJson); +Require( + actionRoundTrip.Value is SessionIsReadChangedAction { IsRead: true }, + "Discriminated action union round trip failed."); +Require( + actionJson.Contains("\"type\":\"session/isReadChanged\"", StringComparison.Ordinal), + "Wire enum conversion failed."); + +var snapshot = new Snapshot +{ + Resource = "ahp-root://", + FromSeq = 42, + State = new SnapshotState + { + Root = new RootState { Agents = new List() }, + }, +}; +var snapshotRoundTrip = serializer.Deserialize(serializer.Serialize(snapshot)); +Require(snapshotRoundTrip.State.Root?.Agents.Count == 0, "Snapshot union round trip failed."); + +var plainText = serializer.Deserialize("\"hello\""); +Require(plainText.AsText() == "hello", "StringOrMarkdown scalar round trip failed."); + +Require( + AhpJson.Options.GetTypeInfo(typeof(ActionEnvelope)) is not null, + "Generated metadata is missing ActionEnvelope."); + +var pingTransport = new PingLoopbackTransport(serializer); +await using (var pingClient = AhpClient.Connect(pingTransport)) +{ + await pingClient.PingAsync(); + await pingClient.ShutdownAsync(); +} +Require(pingTransport.PingReceived, "PingAsync did not send a ping request."); + +Console.WriteLine("Native AOT serialization and client ping smoke test passed."); + +static void Require(bool condition, string message) +{ + if (!condition) + { + throw new InvalidOperationException(message); + } +} + +sealed class PingLoopbackTransport(IAhpSerializer serializer) : ITransport +{ + private readonly Channel _responses = Channel.CreateUnbounded(); + + public bool PingReceived { get; private set; } + + public ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) + { + var request = serializer.DecodeMessage(message).Request + ?? throw new InvalidOperationException("Expected a JSON-RPC request."); + if (request.Method != "ping") + { + throw new InvalidOperationException($"Expected ping, received {request.Method}."); + } + + PingReceived = true; + using var nullDocument = System.Text.Json.JsonDocument.Parse("null"); + var response = new JsonRpcMessage + { + SuccessResponse = new JsonRpcSuccessResponse + { + Id = request.Id, + Result = nullDocument.RootElement.Clone(), + }, + }; + return _responses.Writer.WriteAsync(serializer.EncodeMessage(response), cancellationToken); + } + + public ValueTask ReceiveAsync(CancellationToken cancellationToken = default) => + _responses.Reader.ReadAsync(cancellationToken); + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _responses.Writer.TryComplete(); + return default; + } + + public ValueTask DisposeAsync() + { + _responses.Writer.TryComplete(); + return default; + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj b/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj index b9f39f1b3..a0b6b92d8 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj @@ -29,6 +29,7 @@ 3.x keeps plain `dotnet test` working against the MTP host. --> + diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ApiQualityTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ApiQualityTests.cs new file mode 100644 index 000000000..12dfb7c5e --- /dev/null +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ApiQualityTests.cs @@ -0,0 +1,123 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AgentHostProtocol.Hosts; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Microsoft.AgentHostProtocol.Tests; + +public sealed class ApiQualityTests +{ + [Fact] + public void AhpJson_OptionsAreReadOnly() + { + Assert.True(AhpJson.Options.IsReadOnly); + Assert.Throws(() => AhpJson.Options.WriteIndented = true); + } + + [Fact] + public void GeneratedJsonContext_IsNotPublicApi() + { + Type? contextType = typeof(Implementation).Assembly.GetType( + "Microsoft.AgentHostProtocol.AgentHostProtocolJsonContext"); + + Assert.NotNull(contextType); + Assert.False(contextType.IsPublic || contextType.IsNestedPublic); + } + + [Fact] + public async Task AhpClient_ConnectSnapshotsCallerConfiguration() + { + var originalTimeProvider = new FakeTimeProvider(); + var config = new ClientConfig + { + SubscriptionBufferCapacity = 1, + TimeProvider = originalTimeProvider, + }; + var snapshot = ClientConfig.Snapshot(config); + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect(clientSide, config); + + config.SubscriptionBufferCapacity = 2; + config.TimeProvider = new FakeTimeProvider(); + using var subscription = client.AttachSubscription("ahp-test://snapshot"); + var progress = new ProgressParams { Channel = "ahp-test://snapshot", ProgressToken = "token" }; + subscription.TrySend(new SubscriptionEventProgress(progress)); + subscription.TrySend(new SubscriptionEventProgress(progress)); + + Assert.True(subscription.Events.TryRead(out _)); + Assert.False(subscription.Events.TryRead(out _)); + Assert.Same(originalTimeProvider, snapshot.TimeProvider); + } + + [Fact] + public void SystemTextJsonAhpSerializer_SnapshotsCallerOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + var serializer = new SystemTextJsonAhpSerializer(options); + + options.WriteIndented = true; + + Assert.Equal( + "{\"name\":\"test\",\"version\":\"1.0\"}", + serializer.Serialize(new Implementation { Name = "test", Version = "1.0" })); + } + + [Fact] + public void StringOrMarkdown_FactoriesRejectNull() + { + Assert.Throws(() => StringOrMarkdown.FromPlain(null!)); + Assert.Throws(() => StringOrMarkdown.FromMarkdown(null!)); + } + + [Fact] + public void HostId_OperatorsUseValueEquality() + { + HostId first = "host-a"; + HostId second = "host-a"; + HostId other = "host-b"; + + Assert.True(first == second); + Assert.False(first != second); + Assert.True(first != other); + } + + [Fact] + public void HostConfig_SnapshotOwnsMutableConfiguration() + { + var subscriptions = new List { "ahp-test://one" }; + var protocolVersions = new List { "2026-01-01" }; + var timeProvider = new FakeTimeProvider(); + var clientConfig = new ClientConfig + { + SubscriptionBufferCapacity = 1, + TimeProvider = timeProvider, + }; + var config = new HostConfig + { + Id = new HostId("host-a"), + InitialSubscriptions = subscriptions, + ProtocolVersions = protocolVersions, + ClientConfig = clientConfig, + TransportFactory = (_, _) => throw new InvalidOperationException(), + }; + + var snapshot = config.Snapshot("client-a"); + subscriptions[0] = "ahp-test://changed"; + protocolVersions[0] = "changed"; + clientConfig.SubscriptionBufferCapacity = 2; + + Assert.Equal("ahp-test://one", Assert.Single(snapshot.InitialSubscriptions!)); + Assert.Equal("2026-01-01", Assert.Single(snapshot.ProtocolVersions!)); + Assert.Equal(1, snapshot.ClientConfig!.SubscriptionBufferCapacity); + Assert.Same(timeProvider, snapshot.ClientConfig.TimeProvider); + } +} diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs index 3a0443d86..cc3beed99 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/ClientTests.cs @@ -9,6 +9,7 @@ using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.AgentHostProtocol; +using Microsoft.Extensions.Time.Testing; using Xunit; namespace Microsoft.AgentHostProtocol.Tests; @@ -579,6 +580,35 @@ public async Task Initialize_SnapshotDeliveredInResult() await serverTask; } + [Fact] + public async Task Initialize_RejectsUnofferedProtocolVersion() + { + var (clientSide, serverSide) = MemTransport.CreatePair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var serverTask = Task.Run( + () => AnswerOneRequestAsync( + serverSide, + "initialize", + new InitializeResult + { + ProtocolVersion = "999.0.0", + Snapshots = new System.Collections.Generic.List(), + }, + cts.Token), + cts.Token); + + await using var client = AhpClient.Connect(clientSide); + var ex = await Assert.ThrowsAsync( + () => client.InitializeAsync( + "test-client", + new[] { ProtocolVersion.Current }, + cancellationToken: cts.Token)); + + Assert.Equal("protocol", ex.Kind); + Assert.Contains("999.0.0", ex.Message, StringComparison.Ordinal); + await serverTask; + } + // D: subscribe round-trip + snapshot. [Fact] public async Task Subscribe_RoundTrip_DeliversSnapshot() @@ -1021,22 +1051,24 @@ public async Task ConnectionState_TransitionsThroughStateChanges() public async Task KeepAlive_PingsWhenCapable() { var transport = new PingCountingTransport(); + var timeProvider = new FakeTimeProvider(); var client = AhpClient.Connect( transport, new ClientConfig { + TimeProvider = timeProvider, KeepAlive = KeepAlivePolicy.Enabled( - interval: TimeSpan.FromMilliseconds(10), - timeout: TimeSpan.FromMilliseconds(10)), + interval: TimeSpan.FromSeconds(10), + timeout: TimeSpan.FromSeconds(10)), }); - // The ping loop runs from construction; wait until it has pinged at least - // twice (proving the loop repeats, not just fires once). - await WaitUntilAsync( - () => transport.PingCount >= 2, - because: "keep-alive loop should issue repeated pings on a capable transport"); + timeProvider.Advance(TimeSpan.FromSeconds(10)); + Assert.Equal(1, await transport.ReadPingCountAsync(TestContext.Current.CancellationToken)); + + timeProvider.Advance(TimeSpan.FromSeconds(10)); + Assert.Equal(2, await transport.ReadPingCountAsync(TestContext.Current.CancellationToken)); - Assert.True(transport.PingCount >= 2, $"expected >=2 pings, got {transport.PingCount}"); + Assert.Equal(2, transport.PingCount); await client.ShutdownAsync(TestContext.Current.CancellationToken); } @@ -1047,17 +1079,35 @@ await WaitUntilAsync( public async Task KeepAlive_DisabledByConfig() { var transport = new PingCountingTransport(); + var timeProvider = new FakeTimeProvider(); var client = AhpClient.Connect( transport, - new ClientConfig { KeepAlive = KeepAlivePolicy.Disabled }); + new ClientConfig + { + TimeProvider = timeProvider, + KeepAlive = KeepAlivePolicy.Disabled, + }); - await Task.Delay(50, TestContext.Current.CancellationToken); + timeProvider.Advance(TimeSpan.FromDays(1)); Assert.Equal(0, transport.PingCount); await client.ShutdownAsync(TestContext.Current.CancellationToken); } + [Fact] + public void KeepAlive_RejectsNonPositiveIntervalAndTimeout() + { + Assert.Throws(() => + KeepAlivePolicy.Ping(TimeSpan.Zero, TimeSpan.FromSeconds(1))); + Assert.Throws(() => + KeepAlivePolicy.Ping(TimeSpan.FromSeconds(-1), TimeSpan.FromSeconds(1))); + Assert.Throws(() => + KeepAlivePolicy.Ping(TimeSpan.FromSeconds(1), TimeSpan.Zero)); + Assert.Throws(() => + KeepAlivePolicy.Ping(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(-1))); + } + // D: keep-alive ping failure — a failed ping is treated as a transport failure: // the client tears down (ConnectionState -> Disconnected) and the transport is // closed exactly once. Mirrors Swift `testKeepAliveFailureDisconnectsClient`. @@ -1065,20 +1115,19 @@ public async Task KeepAlive_DisabledByConfig() public async Task KeepAlive_DisconnectsOnPingFailure() { var transport = new PingCountingTransport(failPing: true); + var timeProvider = new FakeTimeProvider(); var client = AhpClient.Connect( transport, new ClientConfig { + TimeProvider = timeProvider, KeepAlive = KeepAlivePolicy.Enabled( - interval: TimeSpan.FromMilliseconds(10), - timeout: TimeSpan.FromMilliseconds(10)), + interval: TimeSpan.FromSeconds(10), + timeout: TimeSpan.FromSeconds(10)), }); - // The first ping throws; the client must observe that as a transport failure - // and transition to Disconnected. - await WaitUntilAsync( - () => client.ConnectionState == ConnectionState.Disconnected, - because: "a ping failure should tear the client down"); + timeProvider.Advance(TimeSpan.FromSeconds(10)); + await transport.Closed.WaitAsync(TestContext.Current.CancellationToken); Assert.Equal(ConnectionState.Disconnected, client.ConnectionState); // The teardown closes the transport exactly once. @@ -1108,6 +1157,7 @@ internal sealed class PingCountingTransport : IKeepAliveTransport private readonly bool _failPing; private readonly TaskCompletionSource _closedTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Channel _pingCounts = Channel.CreateUnbounded(); private int _pings; private int _closes; private int _closed; @@ -1120,6 +1170,11 @@ internal sealed class PingCountingTransport : IKeepAliveTransport /// The number of times transitioned to closed. public int CloseCount => Volatile.Read(ref _closes); + public Task Closed => _closedTcs.Task; + + public ValueTask ReadPingCountAsync(CancellationToken cancellationToken) => + _pingCounts.Reader.ReadAsync(cancellationToken); + public ValueTask SendAsync(TransportMessage message, CancellationToken cancellationToken = default) { if (Volatile.Read(ref _closed) == 1) throw new AhpTransportException("closed"); @@ -1148,7 +1203,8 @@ public ValueTask CloseAsync(CancellationToken cancellationToken = default) public ValueTask SendPingAsync(TimeSpan timeout, CancellationToken cancellationToken = default) { if (Volatile.Read(ref _closed) == 1) throw new AhpTransportException("closed"); - Interlocked.Increment(ref _pings); + var count = Interlocked.Increment(ref _pings); + _pingCounts.Writer.TryWrite(count); if (_failPing) throw new AhpTransportException("io", "ping failed"); return ValueTask.CompletedTask; } diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs index d05ddc5eb..8a516cd19 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FileClientIdStoreTests.cs @@ -9,6 +9,7 @@ using System; using System.IO; +using System.Runtime.Versioning; using System.Threading.Tasks; using Microsoft.AgentHostProtocol.Hosts; using Xunit; @@ -142,6 +143,44 @@ public async Task FileClientIdStore_FileIsOwnerOnlyOnUnix() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + [UnsupportedOSPlatform("windows")] + public async Task FileClientIdStore_TempFileIsOwnerOnlyBeforeContentIsWrittenOnUnix( + bool useNetStandardNativeCreation) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var inspected = false; + var store = new FileClientIdStore(_tempDir, tempPath => + AssertOwnerOnlyEmptyTempFile(tempPath, ref inspected), useNetStandardNativeCreation); + + await store.StoreAsync(new HostId("h"), "sensitive-client-id", TestContext.Current.CancellationToken); + + Assert.True(inspected); + Assert.Equal( + "sensitive-client-id", + await store.LoadAsync(new HostId("h"), TestContext.Current.CancellationToken)); + } + + [UnsupportedOSPlatform("windows")] + private static void AssertOwnerOnlyEmptyTempFile(string tempPath, ref bool inspected) + { + inspected = true; + Assert.Equal(0, new FileInfo(tempPath).Length); + + var mode = File.GetUnixFileMode(tempPath); + var permissionBits = mode & ( + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, permissionBits); + } + // ── F: directory path is actually a file ────────────────────────────────── // .NET-specific sub-case (Swift's FileClientIdStore swallows directory errors // via `try?` in ensureDirectory; the .NET port surfaces them loudly instead). diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs index 14a2b42e1..e8cebfba1 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/FixRegressionTests.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AgentHostProtocol.Hosts; +using Microsoft.Extensions.Time.Testing; using Xunit; namespace Microsoft.AgentHostProtocol.Tests; @@ -91,23 +92,157 @@ public async Task RequestTimeout_RecordsOutcomeTimeout() }); meterListener.Start(); - var (clientSide, _) = MemTransport.CreatePair(); // server never replies - var cfg = new ClientConfig { DefaultRequestTimeout = TimeSpan.FromMilliseconds(50) }; + var timeProvider = new FakeTimeProvider(); + var (clientSide, serverSide) = MemTransport.CreatePair(); // server never replies + var cfg = new ClientConfig + { + DefaultRequestTimeout = TimeSpan.FromSeconds(30), + TimeProvider = timeProvider, + }; await using var client = AhpClient.Connect(clientSide, cfg); - await Assert.ThrowsAnyAsync(() => - client.RequestAsync("noop", null, TestContext.Current.CancellationToken)); + var request = client.RequestAsync( + "noop", + null, + TestContext.Current.CancellationToken); + await serverSide.ReceiveAsync(TestContext.Current.CancellationToken); + timeProvider.Advance(TimeSpan.FromSeconds(30)); + + await Assert.ThrowsAnyAsync(() => request); Assert.True(sawTimeout, "request.duration should carry ahp.outcome=timeout when the default timeout fires"); } + [Fact] + public async Task RequestTimeout_DoesNotCancelActiveTransportSend() + { + var timeProvider = new FakeTimeProvider(); + var transport = new ControlledSendTransport(); + await using var client = AhpClient.Connect( + transport, + new ClientConfig + { + DefaultRequestTimeout = TimeSpan.FromSeconds(30), + TimeProvider = timeProvider, + }); + + var request = client.RequestAsync( + "slow-send", + null, + TestContext.Current.CancellationToken); + await transport.FirstSendStarted.WaitAsync(TestContext.Current.CancellationToken); + + timeProvider.Advance(TimeSpan.FromSeconds(30)); + + await Assert.ThrowsAnyAsync(() => request); + Assert.False(transport.FirstSendCanceled.IsCompleted); + + transport.ReleaseFirstSend(); + await client.NotifyAsync("after-timeout", new { }, TestContext.Current.CancellationToken); + Assert.Equal(2, transport.SendAttempts); + } + + [Fact] + public async Task RequestTimeout_SkipsCanceledQueuedSend() + { + var timeProvider = new FakeTimeProvider(); + var transport = new ControlledSendTransport(); + await using var client = AhpClient.Connect( + transport, + new ClientConfig + { + DefaultRequestTimeout = TimeSpan.FromSeconds(30), + TimeProvider = timeProvider, + }); + + var firstSend = client.NotifyAsync( + "blocking-send", + new { }, + TestContext.Current.CancellationToken); + await transport.FirstSendStarted.WaitAsync(TestContext.Current.CancellationToken); + + var request = client.RequestAsync( + "queued-request", + null, + TestContext.Current.CancellationToken); + timeProvider.Advance(TimeSpan.FromSeconds(30)); + await Assert.ThrowsAnyAsync(() => request); + + transport.ReleaseFirstSend(); + await firstSend; + await client.NotifyAsync("after-timeout", new { }, TestContext.Current.CancellationToken); + + Assert.Equal(2, transport.SendAttempts); + } + + [Fact] + public async Task RequestTimeout_DuringSerialization_SkipsTransportSend() + { + var timeProvider = new FakeTimeProvider(); + var transport = new CountingTransport(); + var serializer = new BlockingEncodeSerializer(); + await using var client = AhpClient.Connect( + transport, + new ClientConfig + { + DefaultRequestTimeout = TimeSpan.FromSeconds(30), + TimeProvider = timeProvider, + }, + serializer); + + var request = client.RequestAsync( + "slow-serialization", + null, + TestContext.Current.CancellationToken); + await serializer.EncodeStarted.WaitAsync(TestContext.Current.CancellationToken); + + try + { + timeProvider.Advance(TimeSpan.FromSeconds(30)); + await Assert.ThrowsAnyAsync(() => request); + } + finally + { + serializer.ReleaseEncode(); + } + await client.NotifyAsync("after-timeout", new { }, TestContext.Current.CancellationToken); + + Assert.Equal(1, transport.SendAttempts); + } + + [Fact] + public async Task InvalidRequestTimeout_DoesNotRegisterPendingRequest() + { + var (clientSide, _) = MemTransport.CreatePair(); + await using var client = AhpClient.Connect( + clientSide, + new ClientConfig { DefaultRequestTimeout = TimeSpan.MaxValue }); + var nextRequestId = client.NextRequestId; + + await Assert.ThrowsAsync(() => + client.RequestAsync( + "invalid-timeout", + null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, client.PendingRequestCount); + Assert.Equal(nextRequestId, client.NextRequestId); + } + // ── Pre-existing fix: HostEntry.ApplySummaryChange is copy-on-write, so a snapshot // already handed to a consumer is never mutated underneath it (torn-read fix). ── [Fact] public void ApplySummaryChange_DoesNotMutate_AlreadyTakenSnapshot() { - var entry = new HostEntry(new HostId("h"), new HostConfig { Id = new HostId("h") }, "client-1"); + var entry = new HostEntry( + new HostId("h"), + new HostConfig + { + Id = new HostId("h"), + TransportFactory = (_, _) => throw new InvalidOperationException(), + }, + "client-1"); entry.PutSessionSummary(new SessionSummary { Resource = "ahp-session:/s1", @@ -135,7 +270,14 @@ public void ApplySummaryChange_DoesNotMutate_AlreadyTakenSnapshot() [Fact] public void ApplySummaryChange_Meta_OverridesWhenPresent_CarriesOverWhenAbsent() { - var entry = new HostEntry(new HostId("h"), new HostConfig { Id = new HostId("h") }, "client-1"); + var entry = new HostEntry( + new HostId("h"), + new HostConfig + { + Id = new HostId("h"), + TransportFactory = (_, _) => throw new InvalidOperationException(), + }, + "client-1"); var originalMeta = new Dictionary { ["pinned"] = JsonDocument.Parse("true").RootElement, @@ -937,4 +1079,131 @@ public void InputRequestUpsert_DoesNotMutateTheCallerAction() Assert.True(part.Request.Answers!.ContainsKey("q1")); Assert.Equal("pick one (again)", part.Request.Message); } + + private sealed class ControlledSendTransport : ITransport + { + private readonly TaskCompletionSource _firstSendStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _firstSendCanceled = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseFirstSend = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _closed = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _sendAttempts; + + public Task FirstSendStarted => _firstSendStarted.Task; + + public Task FirstSendCanceled => _firstSendCanceled.Task; + + public int SendAttempts => Volatile.Read(ref _sendAttempts); + + public void ReleaseFirstSend() => _releaseFirstSend.TrySetResult(); + + public async ValueTask SendAsync( + TransportMessage message, + CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref _sendAttempts) != 1) + return; + + _firstSendStarted.TrySetResult(); + try + { + await _releaseFirstSend.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + _firstSendCanceled.TrySetResult(); + throw; + } + } + + public async ValueTask ReceiveAsync( + CancellationToken cancellationToken = default) + { + await _closed.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + throw new TransportClosedException(); + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _closed.TrySetResult(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => CloseAsync(); + } + + private sealed class CountingTransport : ITransport + { + private readonly TaskCompletionSource _closed = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _sendAttempts; + + public int SendAttempts => Volatile.Read(ref _sendAttempts); + + public ValueTask SendAsync( + TransportMessage message, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _sendAttempts); + return ValueTask.CompletedTask; + } + + public async ValueTask ReceiveAsync( + CancellationToken cancellationToken = default) + { + await _closed.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + throw new TransportClosedException(); + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _closed.TrySetResult(); + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() => CloseAsync(); + } + + private sealed class BlockingEncodeSerializer : IAhpSerializer + { + private readonly ManualResetEventSlim _releaseEncode = new(initialState: false); + private readonly TaskCompletionSource _encodeStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _encodeCount; + + public Task EncodeStarted => _encodeStarted.Task; + + public void ReleaseEncode() => _releaseEncode.Set(); + + public string Serialize(T value) => SystemTextJsonAhpSerializer.Default.Serialize(value); + + public JsonElement SerializeToElement(T value) => + SystemTextJsonAhpSerializer.Default.SerializeToElement(value); + + public T Deserialize(string json) => + SystemTextJsonAhpSerializer.Default.Deserialize(json); + + public T Deserialize(ReadOnlySpan utf8Json) => + SystemTextJsonAhpSerializer.Default.Deserialize(utf8Json); + + public T Deserialize(JsonElement element) => + SystemTextJsonAhpSerializer.Default.Deserialize(element); + + public JsonRpcMessage DecodeMessage(TransportMessage message) => + SystemTextJsonAhpSerializer.Default.DecodeMessage(message); + + public TransportMessage EncodeMessage(JsonRpcMessage message) + { + if (Interlocked.Increment(ref _encodeCount) == 1) + { + _encodeStarted.TrySetResult(); + _releaseEncode.Wait(TestContext.Current.CancellationToken); + } + + return SystemTextJsonAhpSerializer.Default.EncodeMessage(message); + } + } } diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs index d9897318a..37c6aebd0 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/MultiHostClientTests.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Text.Json; // mirror/client tests that build wire payloads using Microsoft.AgentHostProtocol; using Microsoft.AgentHostProtocol.Hosts; +using Microsoft.Extensions.Time.Testing; using Xunit; namespace Microsoft.AgentHostProtocol.Tests; @@ -19,6 +21,27 @@ public sealed class MultiHostClientTests { private static readonly SystemTextJsonAhpSerializer Ser = SystemTextJsonAhpSerializer.Default; + private sealed class BlockingClientIdStore : IClientIdStore + { + public TaskCompletionSource LoadStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ContinueLoad { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task LoadAsync(HostId host, CancellationToken cancellationToken = default) + { + LoadStarted.TrySetResult(null); + await ContinueLoad.Task.WaitAsync(cancellationToken); + return "client-a"; + } + + public Task StoreAsync( + HostId host, + string clientId, + CancellationToken cancellationToken = default) => Task.CompletedTask; + } + // ── Fake server helpers ─────────────────────────────────────────────── // The receive→decode→dispatch loop lives once in FakeHost; these helpers // build the per-test FakeHost definitions and the response payloads. @@ -145,6 +168,49 @@ public async Task MultiHost_TwoHosts_RegisterAndConnectIndependently() Assert.Equal(2, m.Hosts().Count); } + [Fact] + 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 observedInitialize = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + await using var multiHost = new MultiHostClient(store); + + var config = new HostConfig + { + Id = new HostId("host-a"), + InitialSubscriptions = subscriptions, + ProtocolVersions = protocolVersions, + TransportFactory = (hostId, ct) => + { + var (client, server) = MemTransport.CreatePair(); + _ = Task.Run(() => FakeHost.New() + .OnInitialize((request, side, token) => + { + observedInitialize.TrySetResult( + Ser.Deserialize(request.Params!.Value.GetRawText())); + return RespondInitializeAsync(side, request.Id, token); + }) + .RunAsync(server, ct)); + return Task.FromResult(client); + }, + }; + + var addTask = multiHost.AddHostAsync(config, cts.Token); + await store.LoadStarted.Task.WaitAsync(cts.Token); + subscriptions[0] = "ahp-test://changed"; + protocolVersions[0] = "changed"; + store.ContinueLoad.TrySetResult(null); + + 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)); + } + // ── H: events tagged hostId ──────────────────────────────────────────── [Fact] @@ -280,6 +346,544 @@ await m.AddHostAsync(new HostConfig Assert.Equal(2, maxSeqSeen); } + [Fact] + public async Task MultiHost_Reconnect_UsesLiveSubscriptionsAndServerSequence() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var reconnectParams = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + MemTransport? firstServer = null; + var attempt = 0; + + HostTransportFactory factory = (id, ct) => + { + var (client, server) = MemTransport.CreatePair(); + if (Interlocked.Increment(ref attempt) == 1) + { + firstServer = server; + _ = Task.Run(() => FakeHost.New() + .OnInitialize((request, side, token) => + RespondInitializeWithRootAsync(side, request.Id, null, 0, token)) + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)) + .On("subscribe", (request, side, token) => + FakeHost.RespondResultAsync(side, request.Id, new SubscribeResult(), token)) + .RunAsync(server, cts.Token)); + } + else + { + _ = Task.Run(() => FakeHost.New() + .OnReconnect((request, side, token) => + { + reconnectParams.TrySetResult( + Ser.Deserialize(request.Params!.Value.GetRawText())); + return FakeHost.RespondResultAsync( + side, + request.Id, + new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, + Actions = new List(), + Missing = new List { "copilot:/dynamic" }, + }), + token); + }) + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)) + .RunAsync(server, cts.Token)); + } + return Task.FromResult(client); + }; + + var m = new MultiHostClient(); + await using var _mh = m; + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + TransportFactory = factory, + InitialSubscriptions = new[] { ProtocolVersion.RootResourceUri }, + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(20), + MaxBackoff = TimeSpan.FromMilliseconds(20), + BackoffMultiplier = 1, + }, + }, cts.Token); + + await m.SubscribeAsync(new HostId("h"), "copilot:/dynamic", cts.Token); + await SendActionAsync(firstServer!, ProtocolVersion.RootResourceUri, 41, cts.Token); + await WaitUntilAsync( + () => m.Host(new HostId("h"))?.ServerSeq == 41, + cts.Token); + + var initialGeneration = m.Host(new HostId("h"))!.Generation; + await m.ReconnectAsync(new HostId("h"), cts.Token); + var observed = await reconnectParams.Task.WaitAsync(cts.Token); + await WaitUntilAsync( + () => m.Host(new HostId("h")) is { } host + && host.Generation > initialGeneration + && host.State.Kind == HostStateKind.Connected, + cts.Token, + 8000); + + Assert.Equal(41, observed.LastSeenServerSeq); + Assert.Equal( + new[] { ProtocolVersion.RootResourceUri, "copilot:/dynamic" }, + observed.Subscriptions); + Assert.DoesNotContain("copilot:/dynamic", m.Host(new HostId("h"))!.Subscriptions); + } + + [Fact] + public async Task MultiHost_ReconnectReplay_InstallsReplacementBeforePublishingActions() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var attempt = 0; + var actions = Enumerable.Range(1, 500) + .Select(seq => new ActionEnvelope + { + Channel = ProtocolVersion.RootResourceUri, + ServerSeq = seq, + Action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = seq, + }), + }) + .ToList(); + + HostTransportFactory factory = (id, ct) => + { + var (client, server) = MemTransport.CreatePair(); + var host = FakeHost.New() + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)); + if (Interlocked.Increment(ref attempt) == 1) + { + host.OnInitialize((request, side, token) => + RespondInitializeWithRootAsync(side, request.Id, null, 0, token)); + } + else + { + host.OnReconnect((request, side, token) => + FakeHost.RespondResultAsync( + side, + request.Id, + new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, + Actions = actions, + Missing = new List(), + }), + token)); + } + _ = host.RunAsync(server, cts.Token); + return Task.FromResult(client); + }; + + var m = new MultiHostClient(); + await using var _mh = m; + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + TransportFactory = factory, + InitialSubscriptions = new[] { ProtocolVersion.RootResourceUri }, + }, cts.Token); + + var replayEvents = m.EventsForHost(new HostId("h"), ProtocolVersion.RootResourceUri); + var dispatchOnReplay = Task.Run(async () => + { + _ = Assert.IsType(await replayEvents.ReadAsync(cts.Token)); + return await m.DispatchAsync( + new HostId("h"), + new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 501, + }), + ProtocolVersion.RootResourceUri, + cancellationToken: cts.Token); + }, cts.Token); + + await m.ReconnectAsync(new HostId("h"), cts.Token); + var handle = await dispatchOnReplay.WaitAsync(cts.Token); + + Assert.Equal(1, handle.ClientSeq); + } + + [Fact] + public async Task MultiHost_ReconnectReplay_AdvancesSequenceOnlyAfterEachAppliedAction() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var thirdAttemptParams = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var attempt = 0; + + HostTransportFactory factory = (id, ct) => + { + var (client, server) = MemTransport.CreatePair(); + var currentAttempt = Interlocked.Increment(ref attempt); + var host = FakeHost.New() + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)); + if (currentAttempt == 1) + { + host.OnInitialize((request, side, token) => + RespondInitializeWithRootAsync(side, request.Id, null, 0, token)); + } + else + { + host.OnReconnect((request, side, token) => + { + if (currentAttempt == 3) + { + thirdAttemptParams.TrySetResult( + Ser.Deserialize(request.Params!.Value.GetRawText())); + } + + var replay = new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, + Missing = new List(), + Actions = currentAttempt == 2 + ? new List + { + new() + { + Channel = ProtocolVersion.RootResourceUri, + ServerSeq = 10, + Action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 10, + }), + }, + new() + { + Channel = ProtocolVersion.RootResourceUri, + ServerSeq = 11, + Action = new StateAction(new RootAgentsChangedAction + { + Type = ActionType.RootAgentsChanged, + Agents = null!, + }), + }, + } + : new List(), + }; + return FakeHost.RespondResultAsync( + side, + request.Id, + new ReconnectResult(replay), + token); + }); + } + _ = host.RunAsync(server, cts.Token); + return Task.FromResult(client); + }; + + var m = new MultiHostClient(); + await using var _mh = m; + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + TransportFactory = factory, + ReconnectPolicy = new ReconnectPolicy + { + InitialBackoff = TimeSpan.FromMilliseconds(1), + MaxBackoff = TimeSpan.FromMilliseconds(1), + BackoffMultiplier = 1, + MaxAttempts = 1, + }, + }, cts.Token); + + await m.ReconnectAsync(new HostId("h"), cts.Token); + await WaitForHostStateAsync( + m, + new HostId("h"), + state => state.Kind == HostStateKind.Failed, + cts.Token); + Assert.Equal(10, m.Host(new HostId("h"))!.ServerSeq); + + await m.ReconnectAsync(new HostId("h"), cts.Token); + var observed = await thirdAttemptParams.Task.WaitAsync(cts.Token); + + Assert.Equal(10, observed.LastSeenServerSeq); + } + + [Fact] + public async Task MultiHost_ReconnectSnapshot_AppliesStateAndRetainsStatelessSubscriptions() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var initializeCount = 0; + const string sessionResource = "ahp-session:/s1"; + + HostTransportFactory factory = (id, ct) => + { + var (client, server) = MemTransport.CreatePair(); + _ = Task.Run(() => FakeHost.New() + .OnInitialize((request, side, token) => + { + Interlocked.Increment(ref initializeCount); + return RespondInitializeWithRootAsync(side, request.Id, null, 1, token); + }) + .OnReconnect((request, side, token) => + FakeHost.RespondResultAsync( + side, + request.Id, + new ReconnectResult(new ReconnectSnapshotResult + { + Type = ReconnectResultType.Snapshot, + Snapshots = new List + { + new() + { + Resource = ProtocolVersion.RootResourceUri, + FromSeq = 77, + State = new SnapshotState + { + Root = new RootState + { + Agents = new List(), + ActiveSessions = 9, + }, + }, + }, + new() + { + Resource = sessionResource, + FromSeq = 78, + State = new SnapshotState + { + Session = new SessionState + { + Provider = "test", + Title = "Restored session", + ActiveClients = new List(), + Chats = new List(), + }, + }, + }, + }, + }), + token)) + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)) + .RunAsync(server, cts.Token)); + return Task.FromResult(client); + }; + + var m = new MultiHostClient(); + await using var _mh = m; + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + TransportFactory = factory, + InitialSubscriptions = new[] { ProtocolVersion.RootResourceUri, sessionResource, "copilot:/missing" }, + }, cts.Token); + + var sessionEvents = m.EventsForHost(new HostId("h"), sessionResource); + var initialGeneration = m.Host(new HostId("h"))!.Generation; + await m.ReconnectAsync(new HostId("h"), cts.Token); + var snapshotEvent = Assert.IsType( + await sessionEvents.ReadAsync(cts.Token)); + await WaitUntilAsync( + () => m.Host(new HostId("h")) is { } host + && host.Generation > initialGeneration + && host.State.Kind == HostStateKind.Connected, + cts.Token, + 8000); + + var snapshot = m.Host(new HostId("h"))!; + Assert.Equal(1, Volatile.Read(ref initializeCount)); + Assert.Equal(78, snapshot.ServerSeq); + Assert.Equal(9, snapshot.ActiveSessions); + Assert.Equal(sessionResource, snapshotEvent.Snapshot.Resource); + Assert.Equal( + "Restored session", + Assert.IsType(snapshotEvent.Snapshot.State.Session).Title); + Assert.Contains(ProtocolVersion.RootResourceUri, snapshot.Subscriptions); + Assert.Contains(sessionResource, snapshot.Subscriptions); + Assert.Contains("copilot:/missing", snapshot.Subscriptions); + } + + [Fact] + public async Task MultiHost_Initialize_BuffersHandshakeEventsAndAppliesRootActions() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var (client, server) = MemTransport.CreatePair(); + _ = FakeHost.New() + .OnInitialize(async (request, side, token) => + { + await FakeHost.SendNotificationAsync(side, "action", new ActionEnvelope + { + Channel = ProtocolVersion.RootResourceUri, + ServerSeq = 5, + Action = new StateAction(new RootActiveSessionsChangedAction + { + Type = ActionType.RootActiveSessionsChanged, + ActiveSessions = 7, + }), + }, token); + await RespondInitializeWithRootAsync(side, request.Id, null, 0, token); + }) + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)) + .RunAsync(server, cts.Token); + + var m = new MultiHostClient(); + await using var _mh = m; + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + TransportFactory = (_, _) => Task.FromResult(client), + InitialSubscriptions = new[] { ProtocolVersion.RootResourceUri }, + }, cts.Token); + + await WaitUntilAsync( + () => m.Host(new HostId("h")) is { ServerSeq: 5, ActiveSessions: 7 }, + cts.Token); + } + + [Fact] + public async Task MultiHost_SubscribeDuringReconnect_TargetsReplacementConnection() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var reconnectEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseReconnect = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var replacementSubscribed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var attempt = 0; + + HostTransportFactory factory = (_, _) => + { + var (client, server) = MemTransport.CreatePair(); + if (Interlocked.Increment(ref attempt) == 1) + { + _ = FakeHost.New() + .OnInitialize((request, side, token) => + RespondInitializeWithRootAsync(side, request.Id, null, 0, token)) + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)) + .RunAsync(server, cts.Token); + } + else + { + _ = FakeHost.New() + .OnReconnect(async (request, side, token) => + { + reconnectEntered.TrySetResult(); + await releaseReconnect.Task.WaitAsync(token); + await FakeHost.RespondResultAsync( + side, + request.Id, + new ReconnectResult(new ReconnectReplayResult + { + Type = ReconnectResultType.Replay, + Actions = new List(), + Missing = new List(), + }), + token); + }) + .OnListSessions((request, side, token) => + RespondListSessionsAsync(side, request.Id, Array.Empty(), token)) + .On("subscribe", (request, side, token) => + { + replacementSubscribed.TrySetResult(); + return FakeHost.RespondResultAsync(side, request.Id, new SubscribeResult(), token); + }) + .RunAsync(server, cts.Token); + } + return Task.FromResult(client); + }; + + var m = new MultiHostClient(); + await using var _mh = m; + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + TransportFactory = factory, + InitialSubscriptions = new[] { ProtocolVersion.RootResourceUri }, + }, cts.Token); + + await m.ReconnectAsync(new HostId("h"), cts.Token); + await reconnectEntered.Task.WaitAsync(cts.Token); + var subscribeTask = m.SubscribeAsync(new HostId("h"), "copilot:/dynamic", cts.Token); + Assert.False(subscribeTask.IsCompleted); + + releaseReconnect.TrySetResult(); + await subscribeTask; + await replacementSubscribed.Task.WaitAsync(cts.Token); + Assert.Contains("copilot:/dynamic", m.Host(new HostId("h"))!.Subscriptions); + } + + [Fact] + public async Task HostEntry_BeginReconnect_AtomicallyWaitsForReplacementClient() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var (firstTransport, _) = MemTransport.CreatePair(); + var (replacementTransport, _) = MemTransport.CreatePair(); + await using var first = AhpClient.Connect(firstTransport); + await using var replacement = AhpClient.Connect(replacementTransport); + using var entry = new HostEntry( + new HostId("h"), + new HostConfig + { + Id = new HostId("h"), + TransportFactory = (_, _) => throw new NotSupportedException(), + }, + "client"); + entry.SetState(new HostState { Kind = HostStateKind.Connected }); + entry.SetClient(first, "0.1"); + + entry.BeginReconnect(new HostState + { + Kind = HostStateKind.Reconnecting, + Attempt = 1, + }); + var waiting = entry.WaitForClientAsync(cts.Token); + Assert.False(waiting.IsCompleted); + + entry.SetClient(replacement, "0.1"); + + Assert.Same(replacement, await waiting); + + entry.BeginReconnect(new HostState + { + Kind = HostStateKind.Reconnecting, + Attempt = 2, + }); + var terminalWait = entry.WaitForClientAsync(cts.Token); + entry.SetState(new HostState { Kind = HostStateKind.Failed }); + + await Assert.ThrowsAsync(() => terminalWait); + + entry.SetState(new HostState + { + Kind = HostStateKind.Reconnecting, + Attempt = 3, + }); + var retryWait = entry.WaitForClientAsync(cts.Token); + Assert.False(retryWait.IsCompleted); + entry.SetClient(first, "0.1"); + + Assert.Same(first, await retryWait); + } + + [Fact] + public void OpenHostAttempt_CommitAndAbandonmentAreMutuallyExclusive() + { + var committing = new MultiHostClient.OpenHostAttempt(); + committing.BeginCommit(CancellationToken.None); + Assert.False(committing.TryAbandon()); + + var abandoned = new MultiHostClient.OpenHostAttempt(); + Assert.True(abandoned.TryAbandon()); + Assert.Throws( + () => abandoned.BeginCommit(CancellationToken.None)); + } + // ══════════════════════════════════════════════════════════════════════ // Phase 2 (P2-C) — aggregated views, per-host streams, manual reconnect, // typed host errors. Ported from Swift MultiHostClientTests.swift. Drives @@ -313,7 +917,7 @@ private static Task RunFakeServerFullAsync( // pending entry resolves. .AckUnmatchedWithEmpty(); if (injectAfterInit is not null) - host.AfterInitialize((side, c) => RepeatSessionAddedAsync(side, injectAfterInit, c)); + host.AfterInitialize((side, c) => RepeatSessionAddedAsync(side, (SessionSummary)injectAfterInit, c)); return host.RunAsync(serverSide, ct); } @@ -1017,6 +1621,43 @@ await WaitUntilAsync(() => Assert.Equal(HostStateKind.Connected, m.Host(new HostId("slow"))!.State.Kind); } + [Fact] + public async Task MultiHost_Shutdown_DoesNotWaitForFactoryThatIgnoresCancellation() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var reconnectFactoryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var neverCompletes = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var attempts = 0; + + HostTransportFactory factory = (id, ct) => + { + if (Interlocked.Increment(ref attempts) == 2) + { + reconnectFactoryEntered.TrySetResult(); + return neverCompletes.Task; + } + + var (client, server) = MemTransport.CreatePair(); + _ = Task.Run(() => RunFakeServerFullAsync(server, ct: cts.Token)); + return Task.FromResult(client); + }; + + var m = new MultiHostClient(); + await m.AddHostAsync(new HostConfig + { + Id = new HostId("hung"), + TransportFactory = factory, + }, cts.Token); + + await m.ReconnectAsync(new HostId("hung"), cts.Token); + await reconnectFactoryEntered.Task.WaitAsync(cts.Token); + await m.ShutdownAsync(cts.Token); + + Assert.Null(m.Host(new HostId("hung"))); + } + // ── 13. unknown host subscribe → typed exception ─────────────────────── [Fact] @@ -1367,6 +2008,32 @@ await m.AddHostAsync(new HostConfig "ShutdownAsync must not be blocked by a hung transport factory"); } + [Fact] + public async Task MultiHost_Shutdown_NotBlockedByHungInitialTransportFactory() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var factoryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var m = new MultiHostClient(); + + HostTransportFactory factory = async (_, ct) => + { + factoryEntered.TrySetResult(); + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + throw new InvalidOperationException("unreachable"); + }; + + var addTask = m.AddHostAsync(new HostConfig + { + Id = new HostId("hung-initial"), + TransportFactory = factory, + }, CancellationToken.None); + await factoryEntered.Task.WaitAsync(cts.Token); + + await m.ShutdownAsync(cts.Token); + await Assert.ThrowsAnyAsync(async () => await addTask); + } + // ── 21. explicit clientId wins over store ────────────────────────────── // // Pins the clientId-resolution branch in AddHostAsync: an explicit @@ -1602,6 +2269,54 @@ await Assert.ThrowsAnyAsync(() => "AhpClient shutdown on a failed handshake should have closed the transport"); } + [Fact] + public async Task MultiHost_CancelledReconnect_ShutsDownUnderlyingClient() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var reconnectStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var observer = new ClosedObserver(); + var attempt = 0; + + HostTransportFactory factory = (id, ct) => + { + var (client, server) = MemTransport.CreatePair(); + if (Interlocked.Increment(ref attempt) == 1) + { + _ = Task.Run(() => RunFakeServerFullAsync(server, ct: cts.Token)); + return Task.FromResult(client); + } + + _ = Task.Run(async () => + { + try + { + var frame = await server.ReceiveAsync(cts.Token).ConfigureAwait(false); + var message = Ser.DecodeMessage(frame); + if (message.Request?.Method == "reconnect") + reconnectStarted.TrySetResult(null); + await Task.Delay(Timeout.InfiniteTimeSpan, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) { } + }); + return Task.FromResult(new TrackingTransport(client, observer)); + }; + + var m = new MultiHostClient(); + await m.AddHostAsync(new HostConfig + { + Id = new HostId("h"), + TransportFactory = factory, + }, cts.Token); + + await m.ReconnectAsync(new HostId("h"), cts.Token); + await reconnectStarted.Task.WaitAsync(cts.Token); + await m.RemoveHostAsync(new HostId("h"), cts.Token); + + Assert.True(observer.IsClosed); + await m.DisposeAsync(); + } + // ── 27. state during backoff after a drop is Reconnecting ────────────── // // Regression mirror of Swift's testStateDuringBackoffAfterDropIsReconnecting: @@ -1613,6 +2328,7 @@ await Assert.ThrowsAnyAsync(() => public async Task MultiHost_StateDuringBackoffAfterDrop_IsReconnecting() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var timeProvider = new FakeTimeProvider(); var m = new MultiHostClient(); await using var _mh = m; @@ -1641,8 +2357,7 @@ await m.AddHostAsync(new HostConfig Id = new HostId("drop"), Label = "Drop", TransportFactory = factory, - // Long backoff so there is a generous window to observe Reconnecting - // during the sleep (SuperviseAsync sets Reconnecting BEFORE the sleep). + ClientConfig = new ClientConfig { TimeProvider = timeProvider }, ReconnectPolicy = new ReconnectPolicy { InitialBackoff = TimeSpan.FromSeconds(5), @@ -1658,6 +2373,11 @@ await m.AddHostAsync(new HostConfig // the (long) backoff; the state must read Reconnecting during that sleep. await WaitForHostStateAsync(m, new HostId("drop"), s => s.Kind == HostStateKind.Reconnecting, cts.Token, 8000); Assert.Equal(HostStateKind.Reconnecting, m.Host(new HostId("drop"))!.State.Kind); + Assert.Equal(1, Volatile.Read(ref attempts)); + + timeProvider.Advance(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => Volatile.Read(ref attempts) == 2, cts.Token); + Assert.Equal(2, Volatile.Read(ref attempts)); } // ── 28. MultiHostClient shutdown is idempotent ───────────────────────── diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs index 7eb6eaf91..9aefa4c93 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/TransportTests.cs @@ -221,19 +221,19 @@ public async Task TransportMessage_RoundTrip_Request() // SubscriptionBufferCapacity is normalised to the default 256 (NOT to 1 as // Swift's AHPClientConfig does — see featureGaps note). Exercise the REAL // clamp by connecting a REAL AhpClient over a REAL MemTransport and reading - // the mutated config back. Theory covers the 0 and negative cases. + // the effective subscription capacity. The caller-owned config stays unchanged. [Theory] [InlineData(0)] [InlineData(-42)] - public async Task ClientConfig_SubscriptionBuffer_NonPositiveClampsToDefault(int requested) + public async Task ClientConfig_SubscriptionBuffer_NonPositiveUsesDefaultWithoutMutatingCaller(int requested) { var (clientSide, _) = MemTransport.CreatePair(); var cfg = new ClientConfig { SubscriptionBufferCapacity = requested }; await using var client = AhpClient.Connect(clientSide, cfg); - // Connect normalised the non-positive request up to the 256 default. - Assert.Equal(256, cfg.SubscriptionBufferCapacity); + using var subscription = client.AttachSubscription("ahp-test://capacity"); + Assert.Equal(requested, cfg.SubscriptionBufferCapacity); } // ── E: subscription-buffer clamp — positive preserved ────────────────── @@ -262,6 +262,7 @@ public void ClientConfig_DefaultsAreReasonable() Assert.Equal(256, config.SubscriptionBufferCapacity); Assert.Equal(TimeSpan.FromSeconds(30), config.DefaultRequestTimeout); + Assert.Same(TimeProvider.System, config.TimeProvider); Assert.False(config.KeepAlive.IsEnabled); Assert.Same(KeepAlivePolicy.Disabled, config.KeepAlive); } diff --git a/clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs b/clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs index b38960374..5b2634955 100644 --- a/clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs +++ b/clients/dotnet/tests/AgentHostProtocol.Tests/WebSocketTransportTests.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Specialized; // NameValueCollection (captured request headers) +using System.IO; using System.Net; // HttpListener, IPEndPoint using System.Net.Sockets; // TcpListener (free-port picking) using System.Net.WebSockets; // WebSocket, WebSocketMessageType, ... @@ -117,6 +118,44 @@ public ValueTask DisposeAsync() } } + private sealed class FaultingWebSocket : WebSocket + { + public override WebSocketCloseStatus? CloseStatus => null; + public override string? CloseStatusDescription => null; + public override WebSocketState State => WebSocketState.Open; + public override string? SubProtocol => null; + + public override void Abort() + { + } + + public override Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) => Task.CompletedTask; + + public override Task CloseOutputAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) => Task.CompletedTask; + + public override void Dispose() + { + } + + public override Task ReceiveAsync( + ArraySegment buffer, + CancellationToken cancellationToken) => + Task.FromException( + new WebSocketException(WebSocketError.ConnectionClosedPrematurely)); + + public override Task SendAsync( + ArraySegment buffer, + WebSocketMessageType messageType, + bool endOfMessage, + CancellationToken cancellationToken) => Task.CompletedTask; + } + // ── E: real-socket handshake (HttpListener loopback) ────────────────── // Stands up a real loopback WebSocket server, dials it with the production // WebSocketTransport.ConnectAsync (real ClientWebSocket + real handshake), @@ -207,6 +246,21 @@ await serverWs.SendAsync( await serverTask; } + [Fact] + public async Task NativeTransport_SnapshotsOptionsBeforeConnecting() + { + var options = new WebSocketTransportOptions { MaxMessageBytes = 1 }; + options.ConfigureSocket = _ => options.MaxMessageBytes = 1024; + await using var transport = await WebSocketTransport.ConnectCoreAsync( + new Uri("ws://localhost/"), + options, + static (_, _, _) => Task.CompletedTask, + TestContext.Current.CancellationToken); + + Assert.Equal(1, transport.MaxMessageBytes); + Assert.Equal(1024, options.MaxMessageBytes); + } + // ── E: reject unsupported scheme ────────────────────────────────────── // ClientWebSocket rejects non-ws/wss URIs. A short-timeout CTS guards // against any hang. We catch broadly and assert an exception was raised. @@ -257,33 +311,19 @@ await Assert.ThrowsAsync( } // ── E: abnormal close error ─────────────────────────────────────────── - // On an ABNORMAL close (server aborts the socket without a close frame), - // WebSocketTransport.ReceiveAsync wraps the WebSocketException into a thrown - // Exception ("ahp: websocket closed: ...", see WebSocketTransport.cs ~145). - // Assert that an exception is raised — i.e. NOT a clean TransportClosedException - // drain. + // A WebSocket receive failure is mapped to the transport's stable I/O error + // contract. This is intentionally driven with a deterministic WebSocket test + // double: Linux socket stacks differ in when an aborted loopback connection is + // observed, which made the prior real-socket version hang in hosted CI. [Fact] public async Task WsTransport_AbnormalClose_RaisesTransportError() { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await using var server = LoopbackWsServer.Start(); + await using var transport = new WebSocketTransport( + new FaultingWebSocket(), + maxMessageBytes: 1024); - // Server abruptly aborts the socket (no close handshake) right after accept. - var serverTask = server.AcceptOneAsync((serverWs, ct) => - { - serverWs.Abort(); - return Task.CompletedTask; - }, cts.Token); - - await using var transport = await WebSocketTransport.ConnectAsync(server.WsUri, cancellationToken: cts.Token); - - var ex = await Record.ExceptionAsync(async () => await transport.ReceiveAsync(cts.Token)); - Assert.NotNull(ex); - // An abnormal close must surface as a fault, not a clean - // TransportClosedException drain. WebSocketTransport.ReceiveAsync wraps - // WebSocketException into a plain Exception ("ahp: websocket closed:"). - Assert.IsNotType(ex); - - await serverTask; + var ex = await Assert.ThrowsAsync( + async () => await transport.ReceiveAsync(TestContext.Current.CancellationToken)); + Assert.IsType(ex.InnerException); } } diff --git a/docs/.changes/20260821-dotnet-api-quality.json b/docs/.changes/20260821-dotnet-api-quality.json new file mode 100644 index 000000000..efc2008f9 --- /dev/null +++ b/docs/.changes/20260821-dotnet-api-quality.json @@ -0,0 +1,5 @@ +{ + "type": "changed", + "message": "The .NET client now supports trimming and Native AOT through generated JSON metadata, snapshots caller-owned configuration, freezes serializer options, validates protocol negotiation, restores live reconnect state correctly, enforces valid `StringOrMarkdown` values, and consistently uses value-semantic `HostId` APIs.", + "targets": ["dotnet"] +} diff --git a/docs/.changes/20260821-dotnet-client-id-permissions.json b/docs/.changes/20260821-dotnet-client-id-permissions.json new file mode 100644 index 000000000..d18714366 --- /dev/null +++ b/docs/.changes/20260821-dotnet-client-id-permissions.json @@ -0,0 +1,5 @@ +{ + "type": "security", + "message": "The .NET `FileClientIdStore` now establishes owner-only Unix permissions before writing client-ID bytes and fails closed when it cannot do so.", + "targets": ["dotnet"] +} diff --git a/docs/.changes/20260823-dotnet-time-provider.json b/docs/.changes/20260823-dotnet-time-provider.json new file mode 100644 index 000000000..0dabd967d --- /dev/null +++ b/docs/.changes/20260823-dotnet-time-provider.json @@ -0,0 +1,5 @@ +{ + "type": "added", + "message": "`ClientConfig.TimeProvider` enables deterministic request timeout, keep-alive, reconnect scheduling, and host timestamps.", + "targets": ["dotnet"] +} diff --git a/docs/.changes/20260824-dotnet-review-feedback.json b/docs/.changes/20260824-dotnet-review-feedback.json new file mode 100644 index 000000000..83937102a --- /dev/null +++ b/docs/.changes/20260824-dotnet-review-feedback.json @@ -0,0 +1,5 @@ +{ + "type": "fixed", + "message": "The .NET multi-host runtime now publishes reconnect snapshots, installs replacement clients before replay, commits replay cursors per applied action, and cannot be wedged by a non-cooperative transport factory.", + "targets": ["dotnet"] +} diff --git a/scripts/generate-csharp.ts b/scripts/generate-csharp.ts index 98a819ec8..4c5711d05 100644 --- a/scripts/generate-csharp.ts +++ b/scripts/generate-csharp.ts @@ -908,13 +908,22 @@ internal sealed class ToolInputConverter : JsonConverter { return new ToolInput { Inline = reader.GetString() }; } - return new ToolInput { ContentRef = JsonSerializer.Deserialize(ref reader, options) }; + return new ToolInput + { + ContentRef = JsonSerializer.Deserialize( + ref reader, + AhpJsonTypeInfo.Get(options)) + }; } public override void Write(Utf8JsonWriter writer, ToolInput value, JsonSerializerOptions options) { if (value.Inline is not null) { writer.WriteStringValue(value.Inline); return; } - if (value.ContentRef is not null) { JsonSerializer.Serialize(writer, value.ContentRef, options); return; } + if (value.ContentRef is not null) + { + JsonSerializer.Serialize(writer, value.ContentRef, AhpJsonTypeInfo.Get(options)); + return; + } writer.WriteNullValue(); } }`; @@ -1285,57 +1294,57 @@ internal sealed class SnapshotStateConverter : JsonConverter root.TryGetProperty("origin", out _) && root.TryGetProperty("sessions", out _)) { - result.AutomationRun = root.Deserialize(options); + result.AutomationRun = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("automations", out _)) { - result.Automations = root.Deserialize(options); + result.Automations = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("turns", out _)) { - result.Chat = root.Deserialize(options); + result.Chat = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("lifecycle", out _)) { // SessionState is discriminated on its required \`lifecycle\` field. // (It no longer carries \`summary\`; that field was removed when the // session state was flattened.) - result.Session = root.Deserialize(options); + result.Session = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("content", out _)) { - result.Terminal = root.Deserialize(options); + result.Terminal = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("status", out _) && root.TryGetProperty("files", out _)) { - result.Changeset = root.Deserialize(options); + result.Changeset = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("root", out _) && root.TryGetProperty("recursive", out _)) { - result.ResourceWatch = root.Deserialize(options); + result.ResourceWatch = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (root.TryGetProperty("annotations", out _)) { - result.Annotations = root.Deserialize(options); + result.Annotations = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else { - result.Root = root.Deserialize(options); + result.Root = root.Deserialize(AhpJsonTypeInfo.Get(options)); } return result; } public override void Write(Utf8JsonWriter writer, SnapshotState value, JsonSerializerOptions options) { - if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, options); return; } - if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, options); return; } - if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, options); return; } - if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, options); return; } - if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, options); return; } - if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, options); return; } - if (value.ResourceWatch is not null) { JsonSerializer.Serialize(writer, value.ResourceWatch, options); return; } - if (value.Annotations is not null) { JsonSerializer.Serialize(writer, value.Annotations, options); return; } - if (value.Root is not null) { JsonSerializer.Serialize(writer, value.Root, options); return; } + if (value.AutomationRun is not null) { JsonSerializer.Serialize(writer, value.AutomationRun, AhpJsonTypeInfo.Get(options)); return; } + if (value.Automations is not null) { JsonSerializer.Serialize(writer, value.Automations, AhpJsonTypeInfo.Get(options)); return; } + if (value.Chat is not null) { JsonSerializer.Serialize(writer, value.Chat, AhpJsonTypeInfo.Get(options)); return; } + if (value.Session is not null) { JsonSerializer.Serialize(writer, value.Session, AhpJsonTypeInfo.Get(options)); return; } + if (value.Terminal is not null) { JsonSerializer.Serialize(writer, value.Terminal, AhpJsonTypeInfo.Get(options)); return; } + if (value.Changeset is not null) { JsonSerializer.Serialize(writer, value.Changeset, AhpJsonTypeInfo.Get(options)); return; } + if (value.ResourceWatch is not null) { JsonSerializer.Serialize(writer, value.ResourceWatch, AhpJsonTypeInfo.Get(options)); return; } + if (value.Annotations is not null) { JsonSerializer.Serialize(writer, value.Annotations, AhpJsonTypeInfo.Get(options)); return; } + if (value.Root is not null) { JsonSerializer.Serialize(writer, value.Root, AhpJsonTypeInfo.Get(options)); return; } writer.WriteNullValue(); } }`; @@ -2436,19 +2445,19 @@ internal sealed class JsonRpcMessageConverter : JsonConverter var msg = new JsonRpcMessage(); if (hasMethod && hasId) { - msg.Request = root.Deserialize(options); + msg.Request = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (hasMethod) { - msg.Notification = root.Deserialize(options); + msg.Notification = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (hasError) { - msg.ErrorResponse = root.Deserialize(options); + msg.ErrorResponse = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else if (hasResult) { - msg.SuccessResponse = root.Deserialize(options); + msg.SuccessResponse = root.Deserialize(AhpJsonTypeInfo.Get(options)); } else { @@ -2459,10 +2468,10 @@ internal sealed class JsonRpcMessageConverter : JsonConverter public override void Write(Utf8JsonWriter writer, JsonRpcMessage value, JsonSerializerOptions options) { - if (value.Request is not null) { JsonSerializer.Serialize(writer, value.Request, options); return; } - if (value.SuccessResponse is not null) { JsonSerializer.Serialize(writer, value.SuccessResponse, options); return; } - if (value.ErrorResponse is not null) { JsonSerializer.Serialize(writer, value.ErrorResponse, options); return; } - if (value.Notification is not null) { JsonSerializer.Serialize(writer, value.Notification, options); return; } + if (value.Request is not null) { JsonSerializer.Serialize(writer, value.Request, AhpJsonTypeInfo.Get(options)); return; } + if (value.SuccessResponse is not null) { JsonSerializer.Serialize(writer, value.SuccessResponse, AhpJsonTypeInfo.Get(options)); return; } + if (value.ErrorResponse is not null) { JsonSerializer.Serialize(writer, value.ErrorResponse, AhpJsonTypeInfo.Get(options)); return; } + if (value.Notification is not null) { JsonSerializer.Serialize(writer, value.Notification, AhpJsonTypeInfo.Get(options)); return; } writer.WriteNullValue(); } } @@ -2648,6 +2657,89 @@ function generateTelemetryFile(project: Project): string { return `${fileHeader()}\n${body.join('\n')}\n`; } +function generateJsonSerializerContext(generatedFiles: readonly string[]): string { + const serializableTypes = new Set(['Dictionary', 'StringOrMarkdown']); + const declaration = + /^public\s+(?:(?:sealed|abstract|readonly|partial)\s+)*(?:record(?:\s+(?:class|struct))?|class|struct|enum)\s+([A-Za-z_][A-Za-z0-9_]*)/gm; + + for (const source of generatedFiles) { + for (const match of source.matchAll(declaration)) { + serializableTypes.add(match[1]); + } + } + + const attributes = [...serializableTypes] + .sort((a, b) => a.localeCompare(b)) + .map((name) => `[JsonSerializable(typeof(${name}))]`) + .join('\n'); + + return `${fileHeader()} +${attributes} +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + GenerationMode = JsonSourceGenerationMode.Metadata)] +internal partial class AgentHostProtocolJsonContext : JsonSerializerContext +{ +} +`; +} + +function generateReducerMetadata(project: Project): string { + const actionTypes = new Set(); + for (const variant of ACTION_VARIANTS) { + const typeName = variant.tsInterface === '_merged_' + ? 'SessionToolCallConfirmedAction' + : variant.tsInterface === '_merged_chat_' + ? 'ChatToolCallConfirmedAction' + : variant.tsInterface === '_hand_written_session_truncated_' + ? 'SessionTruncatedAction' + : variant.tsInterface === '_hand_written_session_toolcallcontent_' + ? 'SessionToolCallContentChangedAction' + : variant.tsInterface === '_hand_written_session_action_' + ? `${variant.variantName}Action` + : stripIPrefix(variant.tsInterface); + actionTypes.add(typeName); + } + + const cases = [...actionTypes] + .sort((a, b) => a.localeCompare(b)) + .map((name) => ` case ${name} value:\n actionType = value.Type;\n return true;`) + .join('\n'); + const actionTypeEnum = findEnum(project, 'ActionType'); + if (!actionTypeEnum) { + throw new Error('ActionType enum not found'); + } + const wireCases = actionTypeEnum.getMembers() + .map((member) => [member.getName(), String(member.getValue())] as const) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, wire]) => ` ActionType.${name} => ${JSON.stringify(wire)},`) + .join('\n'); + + return `${fileHeader()} +internal static class GeneratedActionMetadata +{ + public static bool TryGetActionType(object action, out ActionType actionType) + { + switch (action) + { +${cases} + default: + actionType = default; + return false; + } + } + + public static string GetWireName(ActionType actionType) => + actionType switch + { +${wireCases} + _ => throw new ArgumentOutOfRangeException(nameof(actionType)), + }; +} +`; +} + // ─── Main Entry Point ──────────────────────────────────────────────────────── export function generateCSharpPackage(project: Project, outputDir: string): void { @@ -2666,12 +2758,32 @@ export function generateCSharpPackage(project: Project, outputDir: string): void const srcDir = path.join(outputDir, 'src', 'AgentHostProtocol.Abstractions', 'Generated'); fs.mkdirSync(srcDir, { recursive: true }); - fs.writeFileSync(path.join(srcDir, 'State.generated.cs'), generateStateFile(project)); - fs.writeFileSync(path.join(srcDir, 'Actions.generated.cs'), generateActionsFile(project)); - fs.writeFileSync(path.join(srcDir, 'Commands.generated.cs'), generateCommandsFile(project)); - fs.writeFileSync(path.join(srcDir, 'Notifications.generated.cs'), generateNotificationsFile(project)); - fs.writeFileSync(path.join(srcDir, 'Errors.generated.cs'), generateErrorsFile(project)); - fs.writeFileSync(path.join(srcDir, 'Messages.generated.cs'), generateMessagesFile()); - fs.writeFileSync(path.join(srcDir, 'Version.generated.cs'), generateVersionFile(project)); - fs.writeFileSync(path.join(srcDir, 'Telemetry.generated.cs'), generateTelemetryFile(project)); + const state = generateStateFile(project); + const actions = generateActionsFile(project); + const commands = generateCommandsFile(project); + const notifications = generateNotificationsFile(project); + const errors = generateErrorsFile(project); + const messages = generateMessagesFile(); + const version = generateVersionFile(project); + const telemetry = generateTelemetryFile(project); + + fs.writeFileSync(path.join(srcDir, 'State.generated.cs'), state); + fs.writeFileSync(path.join(srcDir, 'Actions.generated.cs'), actions); + fs.writeFileSync(path.join(srcDir, 'Commands.generated.cs'), commands); + fs.writeFileSync(path.join(srcDir, 'Notifications.generated.cs'), notifications); + fs.writeFileSync(path.join(srcDir, 'Errors.generated.cs'), errors); + fs.writeFileSync(path.join(srcDir, 'Messages.generated.cs'), messages); + fs.writeFileSync(path.join(srcDir, 'Version.generated.cs'), version); + fs.writeFileSync(path.join(srcDir, 'Telemetry.generated.cs'), telemetry); + fs.writeFileSync( + path.join(srcDir, 'JsonSerializerContext.generated.cs'), + generateJsonSerializerContext([state, actions, commands, notifications, errors, messages]), + ); + + const runtimeGeneratedDir = path.join(outputDir, 'src', 'AgentHostProtocol', 'Generated'); + fs.mkdirSync(runtimeGeneratedDir, { recursive: true }); + fs.writeFileSync( + path.join(runtimeGeneratedDir, 'ActionMetadata.generated.cs'), + generateReducerMetadata(project), + ); }