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."),
]));
}