diff --git a/Directory.Packages.props b/Directory.Packages.props index d7e0ac8e..46cb1074 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,6 @@ - diff --git a/README.md b/README.md index bab2b154..f7ff9f20 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ roadmap, and [`docs/research/`](docs/research/) for the research notes behind th | Package | Description | | --- | --- | | `OpenCode.Sdk` | Core: typed HTTP client, SSE event streaming, `opencode serve` launcher | -| `OpenCode.Sdk.Extensions` | DI integration: `AddOpenCodeClient()`, `IHttpClientFactory` wiring, options binding | +| `OpenCode.Sdk.Extensions` | DI integration: `AddOpenCode()`, singleton client family, options binding | ## License diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bef379ae..471bf5e6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -61,15 +61,17 @@ enrichments. PR #26 is merged. The #34 contract-test consolidation landed as its follow-up PR: one shared contract scaffold (`ContractScenario`, `WireBodyData`), a single `FixtureLoader`/`RecordingHttpHandler` pair compiled from `tests/Shared` into both the SDK and Extensions test projects, and the previously unobserved object-envelope -`{"data":null}` refusal pinned by test. +`{"data":null}` refusal pinned by test (the support pair under `tests/Shared` is consumed +by the SDK test project; the Extensions tests are registration-topology only). **M3 is open** (plan: `superpowers/plans/2026-08-15-m3-plan.md`; decisions: research log -Session 24). Q92 sealed simplicity-first construction (ADR-0010): the transport -constructor internalizes, Extensions goes factory-less singleton, and the Q91 guard -machinery deletes — executing as the reshape PR that opens the M3 runway and closes #31 -by construction. #28, #32, and #33 are sealed with their execution homes recorded -(Session 24); the location + merged-Request design session's marshalling seals are still -pending. Remaining M2 breadth batches interleave after the reshape lands. +Session 24). Q92's simplicity-first construction (ADR-0010) is landed — Arc 1 complete: +the transport constructor is internal friend-assembly test surface, Extensions registers +a factory-less singleton client family with a roster contract test, and the Q91 guard +machinery is deleted — closing #31 by construction. #28, #32, and #33 are sealed with +their execution homes recorded (Session 24); the location + merged-Request design +session's marshalling seals (Arc 2) are still pending. Remaining M2 breadth batches +interleave next, each against the marshalling design once it seals. ## Milestones diff --git a/docs/superpowers/plans/2026-08-15-m3-plan.md b/docs/superpowers/plans/2026-08-15-m3-plan.md index ea6f7d66..468bd4fc 100644 --- a/docs/superpowers/plans/2026-08-15-m3-plan.md +++ b/docs/superpowers/plans/2026-08-15-m3-plan.md @@ -11,20 +11,20 @@ auto-reconnect), doc 15 §5a/§6 (placement census), research log Sessions 22 (Q ## Arc 1 — Construction & transport reshape (Q92, ADR-0010; own PR, first on the runway) -- [ ] The `(HttpClient, options)` constructor internalizes; IVT covers the repository's +- [x] The `(HttpClient, options)` constructor internalizes; IVT covers the repository's tests and benchmarks (contract tests and `ContractScenario` keep compiling unchanged). -- [ ] `Pipeline` always owns its transport: `_ownsHttpClient` and the borrowed-path - disposal branch retire; the anonymous-mode `Authorization` refusal and injected - `BaseAddress` refusal delete with their tests (the Q90 password semantics — `null` - anonymous, blank refused — stay pinned). -- [ ] Extensions rebuilds factory-less: `AddOpenCode` registers one singleton root client +- [x] `Pipeline` keeps its internal ownership flag for the injected test path; the + anonymous-mode `Authorization` refusal and injected `BaseAddress` refusal delete + with their tests (the Q90 password semantics — `null` anonymous, blank refused — + stay pinned). +- [x] Extensions rebuilds factory-less: `AddOpenCode` registers one singleton root client over the owned transport, sub-clients resolve from that same instance, the return type becomes `IServiceCollection`, and `Microsoft.Extensions.Http` leaves the dependency set. -- [ ] The roster contract test (from #31): every generated client family resolves through +- [x] The roster contract test (from #31): every generated client family resolves through DI; the next family cannot silently lag the registration list. -- [ ] PublicApi baseline re-review (constructor removal, Extensions signature) and the +- [x] PublicApi baseline re-review (constructor removal, Extensions signature) and the sandbox showcase update. Closes #31. ## Arc 2 — Location + merged-Request design session (seals before any body+query admission) diff --git a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj index 272f450f..048339c8 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj +++ b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj @@ -2,14 +2,13 @@ $(AllTargetFrameworks) - Dependency injection and IHttpClientFactory integration for the unofficial opencode .NET SDK. - opencode;sdk;dependency-injection;httpclientfactory;options + Dependency injection integration for the unofficial opencode .NET SDK. + opencode;sdk;dependency-injection;options true - @@ -17,14 +16,4 @@ - - - - - - - - - - diff --git a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs index c74dd3f0..c88b17e9 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs +++ b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs @@ -6,19 +6,17 @@ namespace Microsoft.Extensions.DependencyInjection; /// -/// Registers the opencode client family over IHttpClientFactory: a typed -/// riding factory-managed transport, plus every sub-client -/// resolved from it so a consumer can inject directly. The -/// returned is the composition seam — chain -/// AddHttpMessageHandler, resilience handlers, or telemetry instrumentation onto it. +/// Registers the opencode client family as singletons: one +/// owning its transport for the container's lifetime, plus every sub-client resolved from +/// that same instance so a consumer can inject directly. /// public static class OpenCodeServiceCollectionExtensions { /// Registers the opencode client, shaping its options in code. /// The service collection to register with. /// Shapes the client options; the endpoint is required. - /// The typed-client builder for transport composition. - public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, Action configure) + /// The service collection for chaining. + public static IServiceCollection AddOpenCode(this IServiceCollection services, Action configure) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); @@ -30,10 +28,10 @@ public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, A /// Registers the opencode client, binding its options from configuration. /// The service collection to register with. /// The configuration section carrying the client options. - /// The typed-client builder for transport composition. + /// The service collection for chaining. [RequiresDynamicCode("Configuration binding uses reflection over the options type; prefer the configure-action overload on native AOT.")] [RequiresUnreferencedCode("Configuration binding may require members trimming removes; prefer the configure-action overload when trimming.")] - public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddOpenCode(this IServiceCollection services, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); @@ -42,15 +40,15 @@ public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, I return AddOpenCodeCore(services); } - private static IHttpClientBuilder AddOpenCodeCore(IServiceCollection services) + private static IServiceCollection AddOpenCodeCore(IServiceCollection services) { - // A named factory client bound as the typed client: the factory owns the HttpClient - // and its handler rotation, so the client registers transient and disposes nothing. - var builder = services.AddHttpClient(nameof(OpenCodeClient)) - .AddTypedClient(static (httpClient, provider) => new OpenCodeClient( - httpClient, - provider.GetRequiredService>().Value)); - _ = services.AddTransient(static SessionsClient (provider) => provider.GetRequiredService().Sessions); - return builder; + // One singleton client owns the transport for the container's lifetime (pooled + // connection lifetime keeps it healthy on modern TFMs); sub-clients resolve from + // that same instance, so every injection shares one pipeline and the container + // disposes one client at shutdown. + _ = services.AddSingleton(static provider => new OpenCodeClient( + provider.GetRequiredService>().Value)); + _ = services.AddSingleton(static SessionsClient (provider) => provider.GetRequiredService().Sessions); + return services; } } diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index a4cbbfed..783772c4 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -11,7 +11,6 @@ internal sealed class Pipeline : IDisposable { private readonly AuthenticationHeaderValue? _authorization; private readonly string _endpointBase; - private readonly bool _enforceAnonymousDefaults; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; private readonly ProductInfoHeaderValue _userAgent; @@ -47,29 +46,6 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt nameof(options)); } - // The pipeline builds every request URI absolute from Endpoint, so a BaseAddress on - // an injected client would never be consulted; refuse the conflict instead of - // silently ignoring half the caller's configuration. - if (!ownsHttpClient && httpClient.BaseAddress is not null) - { - throw new ArgumentException( - "The supplied HttpClient carries a BaseAddress, which this SDK never consults — requests are " + - "built absolute from OpenCodeClientOptions.Endpoint. Remove the BaseAddress or change the Endpoint.", - nameof(httpClient)); - } - - // Anonymous mode cannot be expressed by omission over an injected client: HttpClient - // copies its default headers onto every request that lacks the header, so a default - // Authorization would ride requests this SDK promises are anonymous. Refuse the - // conflict instead of silently sending a foreign credential. - if (!ownsHttpClient && password is null && CarriesDefaultAuthorization(httpClient)) - { - throw new ArgumentException( - "The options request anonymous requests (Password is null), but the supplied HttpClient's " + - "default headers carry an Authorization header. Remove the default header or set a password.", - nameof(httpClient)); - } - _httpClient = httpClient; _ownsHttpClient = ownsHttpClient; _endpointBase = EndpointPolicy.Normalize(endpoint); @@ -80,11 +56,6 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt _authorization = password is null ? null : new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); - - // Default headers stay legally mutable after construction, so the anonymous-mode - // refusal must also run per send; owned transports never expose their client and - // authenticated sends override the default, so only anonymous injected clients pay. - _enforceAnonymousDefaults = !ownsHttpClient && _authorization is null; } public static Pipeline Create(OpenCodeClientOptions options) @@ -155,9 +126,6 @@ public void Dispose() } } - /// Presence beats parseability: a raw unparseable default value still rides the wire. - private static bool CarriesDefaultAuthorization(HttpClient httpClient) => httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _); - /// The owned transport: pooled connection lifetime keeps DNS rotation alive on modern TFMs. private static HttpClient CreateOwnedHttpClient() { @@ -206,20 +174,6 @@ private async Task ExecuteCoreAsync(HttpMethod meth throw new ArgumentOutOfRangeException(nameof(options), errorBehavior, "Unknown ErrorBehavior value."); } - if (_enforceAnonymousDefaults && CarriesDefaultAuthorization(_httpClient)) - { - throw new OpenCodeTransportException( - "The anonymous opencode client's HttpClient carries a default Authorization header; the request " + - "was refused before sending. Remove the default header or set a password."); - } - - if (!_ownsHttpClient && _httpClient.BaseAddress is not null) - { - throw new OpenCodeTransportException( - "The injected HttpClient's BaseAddress is set, and this SDK never consults it — requests are " + - "built absolute from the configured Endpoint. The request was refused before sending."); - } - int status; string rawBody; using (var request = new HttpRequestMessage(method, new Uri(_endpointBase + route, UriKind.Absolute))) diff --git a/src/OpenCode.Sdk/OpenCodeClient.cs b/src/OpenCode.Sdk/OpenCodeClient.cs index e38b66db..db473711 100644 --- a/src/OpenCode.Sdk/OpenCodeClient.cs +++ b/src/OpenCode.Sdk/OpenCodeClient.cs @@ -22,11 +22,11 @@ public OpenCodeClient(OpenCodeClientOptions options) } /// - /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. With a password set, the SDK's per-request Authorization header overrides any client default; in anonymous mode a default Authorization header is refused at construction and before every send. + /// Initializes a client over an injected transport the client never disposes; friend-assembly test surface. /// - /// The caller-owned HTTP client. + /// The injected HTTP client. /// The client options; the endpoint is required. - public OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) + internal OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { _pipeline = Pipeline.Create(httpClient, options); _sessions = new SessionsClient(_pipeline); diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index bc18e640..8ea57320 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -20,12 +20,7 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions /// Gets or sets the HTTP basic-authentication password. sends /// anonymous requests — a server without authentication configured expects none. An /// empty or whitespace value is refused with at client - /// construction; the SDK never reads credentials from the environment. On a - /// caller-owned the SDK's per-request Authorization - /// header overrides any client default when a password is set; in anonymous mode a - /// default Authorization header is refused fail-closed at construction and - /// before every send, because the transport would otherwise attach that foreign - /// credential to requests this SDK promises are anonymous. + /// construction; the SDK never reads credentials from the environment. /// public string? Password { get; set; } } diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json b/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json deleted file mode 100644 index 2bd7e2c4..00000000 --- a/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json +++ /dev/null @@ -1 +0,0 @@ -{"healthy":true,"version":"0.0.0-test","pid":100} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj index a905ff3f..b893fa9e 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj @@ -22,24 +22,9 @@ - - - - - - - - - - - - - - - diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs index c5607877..070aa03b 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs @@ -1,9 +1,7 @@ -using System.Net.Http.Headers; -using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenCode.Sdk.Extensions.Tests.Support; -using OpenCode.Sdk.TestSupport; namespace OpenCode.Sdk.Extensions.Tests; @@ -12,47 +10,43 @@ public sealed class OpenCodeServiceCollectionExtensionsTests private static readonly Uri Endpoint = new("http://localhost:4096"); [Test] - public async Task AddOpenCode_Should_Resolve_The_Client_Through_The_Factory_Pipeline() + public async Task AddOpenCode_Should_Resolve_One_Singleton_Client() { - var payload = new FixtureLoader().LoadJson("known-health.json"); - using var handler = RecordingHttpHandler.RespondingJson(payload); - var services = new ServiceCollection(); - _ = services.AddOpenCode(options => options.Endpoint = Endpoint) - .ConfigurePrimaryHttpMessageHandler(() => handler); - using var provider = services.BuildServiceProvider(); + using var provider = BuildProvider(); - var response = await provider.GetRequiredService().GetHealthAsync(); + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); - await Assert.That(response.Health.Healthy).IsTrue(); - await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/health")); + await Assert.That(first).IsSameReferenceAs(second); } [Test] - public async Task AddOpenCode_Should_Register_The_Client_As_Transient_For_Handler_Rotation() + public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_From_The_Same_Root_Instance() { - var services = new ServiceCollection(); - _ = services.AddOpenCode(options => options.Endpoint = Endpoint); - using var provider = services.BuildServiceProvider(); + using var provider = BuildProvider(); - var first = provider.GetRequiredService(); - var second = provider.GetRequiredService(); + var root = provider.GetRequiredService(); + var sessions = provider.GetRequiredService(); - await Assert.That(first).IsNotSameReferenceAs(second); + await Assert.That(sessions).IsSameReferenceAs(root.Sessions); } [Test] - public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_Directly() + public async Task AddOpenCode_Should_Register_Every_Root_Client_Family() { - var payload = new FixtureLoader().LoadJson("known-health.json"); - using var handler = RecordingHttpHandler.RespondingJson(payload); - var services = new ServiceCollection(); - _ = services.AddOpenCode(options => options.Endpoint = Endpoint) - .ConfigurePrimaryHttpMessageHandler(() => handler); - using var provider = services.BuildServiceProvider(); - - var sessions = provider.GetRequiredService(); - - await Assert.That(sessions).IsNotNull(); + using var provider = BuildProvider(); + var root = provider.GetRequiredService(); + var families = typeof(OpenCodeClient).GetProperties() + .Where(property => property.PropertyType.Name.EndsWith("Client", StringComparison.Ordinal)) + .ToList(); + + await Assert.That(families).IsNotEmpty(); + foreach (var family in families) + { + var resolved = provider.GetService(family.PropertyType); + await Assert.That(resolved).IsNotNull(); + await Assert.That(resolved).IsSameReferenceAs(family.GetValue(root)); + } } [Test] @@ -78,60 +72,25 @@ public async Task AddOpenCode_Should_Bind_The_Configuration_Section_Including_Cr var configuration = new ConfigurationBuilder() .AddInMemoryCollection(OpenCodeConfigurationData.ProtectedServer) .Build(); - using var handler = RecordingHttpHandler.RespondingJson(new FixtureLoader().LoadJson("known-health.json")); - var services = new ServiceCollection(); - _ = services.AddOpenCode(configuration.GetSection("OpenCode")) - .ConfigurePrimaryHttpMessageHandler(() => handler); - using var provider = services.BuildServiceProvider(); - - _ = await provider.GetRequiredService().GetHealthAsync(); - - var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secret"))}"; - await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); - } - - [Test] - public async Task AddOpenCode_Should_Compose_Delegating_Handlers_Through_The_Returned_Builder() - { - var payload = new FixtureLoader().LoadJson("known-health.json"); - using var handler = RecordingHttpHandler.RespondingJson(payload); - using var witness = new WitnessHandler(); - var services = new ServiceCollection(); - _ = services.AddOpenCode(options => options.Endpoint = Endpoint) - .AddHttpMessageHandler(() => witness) - .ConfigurePrimaryHttpMessageHandler(() => handler); - using var provider = services.BuildServiceProvider(); - - _ = await provider.GetRequiredService().GetHealthAsync(); - - await Assert.That(witness.CallCount).IsEqualTo(1); - } - - [Test] - public async Task AddOpenCode_Should_Refuse_An_Anonymous_Factory_Client_Carrying_A_Default_Authorization() - { var services = new ServiceCollection(); - _ = services.AddOpenCode(options => options.Endpoint = Endpoint) - .ConfigureHttpClient(static client => - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token")); + _ = services.AddOpenCode(configuration.GetSection("OpenCode")); using var provider = services.BuildServiceProvider(); - var exception = Assert.Throws(() => _ = provider.GetRequiredService()); + var options = provider.GetRequiredService>().Value; - await Assert.That(exception.Message).Contains("Authorization"); + await Assert.That(options.Endpoint).IsEqualTo(Endpoint); + await Assert.That(options.Username).IsEqualTo("admin"); + await Assert.That(options.Password).IsEqualTo("secret"); } [Test] - public async Task AddOpenCode_Should_Refuse_A_Factory_Client_Carrying_A_BaseAddress() + public async Task AddOpenCode_Should_Return_The_Service_Collection_For_Chaining() { var services = new ServiceCollection(); - _ = services.AddOpenCode(options => options.Endpoint = Endpoint) - .ConfigureHttpClient(static client => client.BaseAddress = new Uri("http://localhost:9")); - using var provider = services.BuildServiceProvider(); - var exception = Assert.Throws(() => _ = provider.GetRequiredService()); + var returned = services.AddOpenCode(static options => options.Endpoint = Endpoint); - await Assert.That(exception.Message).Contains("BaseAddress"); + await Assert.That(returned).IsSameReferenceAs(services); } [Test] @@ -154,4 +113,11 @@ public async Task AddOpenCode_Should_Refuse_A_Null_Configuration() _ = await Assert.That(() => new ServiceCollection().AddOpenCode(configuration: null!)) .Throws(); } + + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + _ = services.AddOpenCode(static options => options.Endpoint = Endpoint); + return services.BuildServiceProvider(); + } } diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs deleted file mode 100644 index bab1d382..00000000 --- a/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace OpenCode.Sdk.Extensions.Tests.Support; - -/// Counts the requests flowing through a composed delegating-handler chain. -internal sealed class WitnessHandler : DelegatingHandler -{ - public int CallCount { get; private set; } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - CallCount++; - return base.SendAsync(request, cancellationToken); - } -} diff --git a/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs b/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs deleted file mode 100644 index 27df1015..00000000 --- a/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Diagnostics; -using System.Globalization; - -namespace OpenCode.Sdk.Sandbox; - -/// -/// A consumer-owned delegating handler riding the factory chain — the third extensibility -/// rung in action: the SDK ships no middleware, the ecosystem seam carries it. -/// -internal sealed class ConsoleTimingHandler : DelegatingHandler -{ - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var stopwatch = Stopwatch.StartNew(); - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - stopwatch.Stop(); - Console.WriteLine(string.Create( - CultureInfo.InvariantCulture, - $" [http] {request.Method} {request.RequestUri?.PathAndQuery} -> {(int)response.StatusCode} ({stopwatch.ElapsedMilliseconds} ms)")); - return response; - } -} diff --git a/tests/OpenCode.Sdk.Sandbox/Program.cs b/tests/OpenCode.Sdk.Sandbox/Program.cs index de27e45e..cde2725e 100644 --- a/tests/OpenCode.Sdk.Sandbox/Program.cs +++ b/tests/OpenCode.Sdk.Sandbox/Program.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Hosting; using OpenCode.Sdk; using OpenCode.Sdk.Models; -using OpenCode.Sdk.Sandbox; var endpoint = Environment.GetEnvironmentVariable("OPENCODE_SANDBOX_ENDPOINT"); if (string.IsNullOrWhiteSpace(endpoint)) @@ -20,17 +19,15 @@ await Console.Error.WriteLineAsync( var password = Environment.GetEnvironmentVariable("OPENCODE_PASSWORD") ?? Environment.GetEnvironmentVariable("OPENCODE_SERVER_PASSWORD"); -// The DI composition the Extensions package exists for: AddOpenCode registers the typed -// client over IHttpClientFactory and returns the IHttpClientBuilder, so consumer-owned -// delegating handlers (and resilience/telemetry packages) chain on without SDK middleware. +// The DI composition the Extensions package exists for: AddOpenCode registers one +// singleton client owning its transport, and every sub-client resolves from that same +// instance. var builder = Host.CreateApplicationBuilder(args); -builder.Services.AddTransient(); _ = builder.Services.AddOpenCode(options => - { - options.Endpoint = new Uri(endpoint); - options.Password = string.IsNullOrWhiteSpace(password) ? null : password; - }) - .AddHttpMessageHandler(); +{ + options.Endpoint = new Uri(endpoint); + options.Password = string.IsNullOrWhiteSpace(password) ? null : password; +}); using var host = builder.Build(); var client = host.Services.GetRequiredService(); diff --git a/tests/OpenCode.Sdk.Sandbox/README.md b/tests/OpenCode.Sdk.Sandbox/README.md index 8f6796f4..a9172a4a 100644 --- a/tests/OpenCode.Sdk.Sandbox/README.md +++ b/tests/OpenCode.Sdk.Sandbox/README.md @@ -24,9 +24,7 @@ OPENCODE_SERVER_PASSWORD=123456 opencode2 serve --hostname 127.0.0.1 --port 4096 ``` Then F5 with the `sandbox` profile. The program composes through the Extensions package — -a Generic Host whose `AddOpenCode` registers the typed client over `IHttpClientFactory`, -with the consumer-owned `ConsoleTimingHandler` chained onto the returned -`IHttpClientBuilder` (the third extensibility rung: the SDK ships no middleware) and -`SessionsClient` resolved straight from the container — then drives the breadth surface -end to end: health, `CreateSessionAsync`, `ListSessionsAsync` (typed page + wire cursor), -`GetSessionAsync` on the created handle, and `ListMessagesAsync`. +a Generic Host whose `AddOpenCode` registers one singleton client owning its transport, +with `SessionsClient` resolved straight from the container — then drives the breadth +surface end to end: health, `CreateSessionAsync`, `ListSessionsAsync` (typed page + wire +cursor), `GetSessionAsync` on the created handle, and `ListMessagesAsync`. diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index e900a253..5c101ea4 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -137,47 +137,6 @@ public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Is_Set() await Assert.That(handler.Requests.Single().Authorization).IsNull(); } - [Test] - public async Task Pipeline_Should_Refuse_An_Anonymous_Client_Whose_Defaults_Carry_Authorization() - { - using var handler = new RecordingHttpHandler(); - using var httpClient = new HttpClient(handler); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); - - var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); - - await Assert.That(exception.Message).Contains("Authorization"); - } - - [Test] - public async Task Pipeline_Should_Refuse_A_Raw_Unparseable_Default_Authorization() - { - using var handler = new RecordingHttpHandler(); - using var httpClient = new HttpClient(handler); - _ = httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "???"); - - var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); - - await Assert.That(exception.Message).Contains("Authorization"); - } - - [Test] - public async Task ExecuteAsync_Should_Refuse_A_Default_Authorization_Added_After_Construction() - { - using var handler = new RecordingHttpHandler(); - using var httpClient = new HttpClient(handler); - using var pipeline = CreatePipeline(httpClient); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); - - var exception = await Assert - .That(async () => _ = await pipeline.ExecuteAsync( - HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) - .Throws(); - - await Assert.That(exception!.Message).Contains("Authorization"); - await Assert.That(handler.Requests).IsEmpty(); - } - [Test] public async Task ExecuteAsync_Should_Let_The_Explicit_Password_Win_Over_A_Default_Authorization() { @@ -483,35 +442,6 @@ public async Task ExecuteAsync_Should_Wrap_A_Timeout_During_The_Body_Read_As_A_T await Assert.That(exception!.InnerException).IsTypeOf(); } - [Test] - public async Task Pipeline_Should_Refuse_An_Injected_Client_Carrying_A_BaseAddress() - { - using var handler = new RecordingHttpHandler(); - using var httpClient = new HttpClient(handler); - httpClient.BaseAddress = new Uri("http://other:1"); - - var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); - - await Assert.That(exception.Message).Contains("BaseAddress"); - } - - [Test] - public async Task ExecuteAsync_Should_Refuse_A_BaseAddress_Set_After_Construction() - { - using var handler = new RecordingHttpHandler(); - using var httpClient = new HttpClient(handler); - using var pipeline = CreatePipeline(httpClient); - httpClient.BaseAddress = new Uri("http://other:1"); - - var exception = await Assert - .That(async () => _ = await pipeline.ExecuteAsync( - HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) - .Throws(); - - await Assert.That(exception!.Message).Contains("BaseAddress"); - await Assert.That(handler.Requests).IsEmpty(); - } - [Test] public async Task Dispose_Should_Dispose_The_Owned_Client() { diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index da9f3f24..f267e10c 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -62,7 +62,6 @@ { protected OpenCodeClient() { } public OpenCodeClient(OpenCode.Sdk.OpenCodeClientOptions options) { } - public OpenCodeClient(System.Net.Http.HttpClient httpClient, OpenCode.Sdk.OpenCodeClientOptions options) { } public virtual OpenCode.Sdk.SessionsClient Sessions { get; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt index 208fb047..88023a64 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt @@ -29,11 +29,11 @@ public class OpenCodeClient : IDisposable } /// - /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. With a password set, the SDK's per-request Authorization header overrides any client default; in anonymous mode a default Authorization header is refused at construction and before every send. + /// Initializes a client over an injected transport the client never disposes; friend-assembly test surface. /// - /// The caller-owned HTTP client. + /// The injected HTTP client. /// The client options; the endpoint is required. - public OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) + internal OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { _pipeline = Pipeline.Create(httpClient, options); _widgets = new WidgetsClient(_pipeline); diff --git a/tests/Shared/RecordingHttpHandler.cs b/tests/Shared/RecordingHttpHandler.cs index 7197354c..9296d312 100644 --- a/tests/Shared/RecordingHttpHandler.cs +++ b/tests/Shared/RecordingHttpHandler.cs @@ -27,16 +27,6 @@ public RecordingHttpHandler(Func respon public bool IsDisposed { get; private set; } - public static RecordingHttpHandler RespondingJson(string body) - { - ArgumentNullException.ThrowIfNull(body); - - return new(_ => new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body), - }); - } - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { _requests.Add(new RecordedRequest diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs index a2b8d8fc..e95f4eac 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs @@ -120,7 +120,7 @@ private static IEnumerable EmitRootConstructors(ClientP "Initializes a client that owns its connection to the endpoint.", [new DocumentedParameter("options", "The client options; the endpoint is required.")])); yield return SyntaxFactory.ConstructorDeclaration(client.Name) - .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.InternalKeyword))) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( [ SyntaxFactory.Parameter(SyntaxFactory.Identifier("httpClient")).WithType(TypeSyntaxEmitter.EmitNamed("HttpClient")), @@ -128,11 +128,10 @@ private static IEnumerable EmitRootConstructors(ClientP ]))) .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, httpClientArgument: "httpClient"))) .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( - "Initializes a client over a caller-owned HttpClient; the SDK never disposes it. " + - "With a password set, the SDK's per-request Authorization header overrides any client default; " + - "in anonymous mode a default Authorization header is refused at construction and before every send.", + "Initializes a client over an injected transport the client never disposes; " + + "friend-assembly test surface.", [ - new DocumentedParameter("httpClient", "The caller-owned HTTP client."), + new DocumentedParameter("httpClient", "The injected HTTP client."), new DocumentedParameter("options", "The client options; the endpoint is required."), ])); }