From ccee41ab580bf3392dacc0f5b30615befd010245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 22:21:07 +0300 Subject: [PATCH 01/13] feat(tools): bind and emit the 204 no-content success envelope CheckStatusShape admits exactly one success of 200 or a content-free 204; the envelope plan records its success status and carries null payload names for the new NoContent kind; envelopes emit payload-free records, adapters emit the declared-status arm and refuse a 204 arriving with a body via the new NonEmptyNoContentFailure on the adapter spine. Fixture and snapshots grow the delete-shaped no-content operation. --- src/OpenCode.Sdk/Internal/ResponseAdapter.cs | 6 ++ .../Binding/OperationPlanBinderTests.cs | 51 +++++++++++ ...ce_Virtual_Delegating_Clients.verified.txt | 15 +++ ...ce_Guarded_Response_Envelopes.verified.txt | 32 +++++++ ...roduce_Status_Mapped_Adapters.verified.txt | 42 +++++++++ ...roduce_Escaped_Route_Builders.verified.txt | 20 ++++ .../Support/EmitterPlanFixture.cs | 50 +++++++++- .../Generator/Binding/Models/EnvelopeKind.cs | 3 + .../Generator/Binding/Models/EnvelopePlan.cs | 15 ++- .../Generator/Binding/OperationPlanBinder.cs | 33 ++++++- .../Generator/Emission/EnvelopeDtoEmitter.cs | 14 +-- .../Generator/Emission/EnvelopeEmitter.cs | 91 +++++++++++++------ .../Emission/ResponseAdapterEmitter.cs | 59 +++++++++--- 13 files changed, 376 insertions(+), 55 deletions(-) diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs index b4daf2ae..1b260f2a 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs @@ -44,6 +44,12 @@ protected static TPayload ReadBarePayload(string rawBody, JsonTypeInfo protected static OpenCodeTransportException UndeclaredSuccessFailure(int status) => new($"The opencode API returned undeclared success status {status.ToString(CultureInfo.InvariantCulture)}."); + /// Builds the protocol failure for a declared no-content success arriving with a body. + /// The declared no-content success status code. + /// The transport failure the adapter throws. + protected static OpenCodeTransportException NonEmptyNoContentFailure(int status) => + new($"The opencode API returned no-content status {status.ToString(CultureInfo.InvariantCulture)} with an unexpected body."); + /// /// Reads a typed error tolerantly: malformed JSON yields so the raw /// body remains the only record; an unknown tag keeps its carrier; a known tag outside the diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index b958877f..dfc61685 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -752,6 +752,45 @@ public async Task Bind_Should_Refuse_A_Non_200_Success() await AssertOperationRefusalAsync(document, "v2.health.get", "status 200"); } + [Test] + public async Task Bind_Should_Bind_A_No_Content_Success_Into_A_Payload_Free_Envelope() + { + var document = await BindingTestHost.IngestAsync(NoContentScenario()); + + var plan = BindWidgets(document, "v2.widget.create"); + + var create = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(create.Envelope.Kind).IsEqualTo(EnvelopeKind.NoContent); + await Assert.That(create.Envelope.SuccessStatusCode).IsEqualTo(204); + await Assert.That(create.Envelope.ResponseTypeName).IsEqualTo("WidgetCreateResponse"); + await Assert.That(create.Envelope.PayloadName).IsNull(); + await Assert.That(create.Envelope.PayloadTypeName).IsNull(); + await Assert.That(create.Envelope.EnvelopeDtoTypeName).IsNull(); + await Assert.That(plan.Registry.TypeNames.Contains("WidgetCreateResponseEnvelope", StringComparer.Ordinal)).IsFalse(); + } + + [Test] + public async Task Bind_Should_Record_The_200_Success_Status_On_The_Envelope() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(static _ => { })); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Envelope.SuccessStatusCode).IsEqualTo(200); + } + + [Test] + public async Task Bind_Should_Refuse_A_No_Content_Success_Carrying_Content() + { + var document = await BindingTestHost.IngestAsync(NoContentScenario(static operation => _ = operation + .WithoutResponse(204) + .Response(204, "application/json", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)))); + + await AssertWidgetRefusalAsync(document, "must not carry content", "v2.widget.create"); + } + [Test] public async Task Bind_Should_Refuse_A_Success_Without_Json_Content() { @@ -1118,6 +1157,18 @@ private static SpecScenario WidgetCreateScenario(Action configure .RequestBody(mediaType, configureBody, required) .Response(200, "application/json", schema => schema.Ref("WidgetInfo")))); + private static SpecScenario NoContentScenario(Action? configure = null) => + SpecScenario.Define(spec => spec + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => + { + _ = operation + .RequestBody("application/json", schema => schema.Type("object") + .Property("title", property => property.Type("string"), required: true), required: true) + .WithoutResponse(200) + .Response(204); + configure?.Invoke(operation); + })); + private static EmitPlan BindWidgets(SpecDocument document, string operationId = "v2.widget.list") => new BindingTestHost().Bind( document, 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 88023a64..5340332b 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 @@ -250,4 +250,19 @@ public class WidgetsClient { return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Widgets.CreateWidget, request ?? new WidgetCreateRequest(), OpenCodeJsonContext.Default.WidgetCreateRequest, WidgetCreateResponseAdapter.Instance, requestOptions, cancellationToken); } + + /// + /// Remove one widget + /// + /// The 'widgetID' route value. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetRemoveResponse' envelope. + /// The API returned an error status (declared: 400) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RemoveWidgetAsync(string widgetId, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(widgetId); + return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Widgets.RemoveWidget(widgetId), WidgetRemoveResponseAdapter.Instance, requestOptions, cancellationToken); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt index 1d14b695..8a1f6e9a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt @@ -383,3 +383,35 @@ public sealed record WidgetOverviewResponse : OpenCodeResponse return true; } } + +// File: Widgets/WidgetRemoveResponse.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'DELETE /api/widget/{widgetID}' operation. +/// +public sealed record WidgetRemoveResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'WidgetRemoveResponse' envelope. + /// + public WidgetRemoveResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetRemoveResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt index 3d464fcf..e958a42f 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt @@ -251,3 +251,45 @@ internal sealed class WidgetOverviewResponseAdapter : ResponseAdapter new WidgetOverviewResponse(status, ReadTolerantError(rawBody, null), rawBody)}; } } + +// File: Internal/ResponseAdapters/WidgetRemoveResponseAdapter.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'DELETE /api/widget/{widgetID}' responses onto 'WidgetRemoveResponse'. +/// +internal sealed class WidgetRemoveResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["BadRequestError"]; + private WidgetRemoveResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetRemoveResponseAdapter Instance { get; } = new WidgetRemoveResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetRemoveResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new WidgetRemoveResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new WidgetRemoveResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + _ => new WidgetRemoveResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt index 6245a73e..1129dfcf 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt @@ -116,5 +116,25 @@ public static class OpenCodeRoutes query.AddParentFilter("parentID", request.ParentId); return path + query.Value; } + + /// + /// The 'DELETE /api/widget/{widgetID}' route template. + /// + public const string RemoveWidgetTemplate = "/api/widget/{widgetID}"; + /// + /// Builds the '/api/widget/{widgetID}' route. + /// + /// The 'widgetID' route value. + /// The escaped route. + public static string RemoveWidget(string widgetId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + + return "/api/widget/" + Uri.EscapeDataString(widgetId); + } } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 77b18ea3..7c075c57 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -92,7 +92,7 @@ public static IReadOnlyList CreateClientPlans() => HandleTypeName = "WidgetClient", Parameter = CreateWidgetParameter(), }, - Operations = [CreateOverviewOperation(), CreateWidgetListOperation(), CreateWidgetCreateOperation()], + Operations = [CreateOverviewOperation(), CreateWidgetListOperation(), CreateWidgetCreateOperation(), CreateWidgetRemoveOperation()], }, ]; @@ -112,6 +112,7 @@ private static OperationPlan CreatePingOperation() => PayloadName = "Ping", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Bare, + SuccessStatusCode = 200, }, ErrorMap = new ErrorMapPlan { @@ -144,6 +145,7 @@ private static OperationPlan CreateOverviewOperation() => PayloadName = "Overview", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Bare, + SuccessStatusCode = 200, }, ErrorMap = new ErrorMapPlan { @@ -179,6 +181,7 @@ private static OperationPlan CreateItemOperation() => PayloadName = "Item", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Data, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetItemResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -243,6 +246,7 @@ private static OperationPlan CreateWidgetListOperation() => PayloadName = "Widgets", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.CursorList, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetListResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -293,6 +297,7 @@ private static OperationPlan CreateItemListOperation() => PayloadName = "Items", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.CursorList, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetItemListResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -333,6 +338,7 @@ private static OperationPlan CreateWidgetCreateOperation() => PayloadName = "Widget", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Data, + SuccessStatusCode = 200, EnvelopeDtoTypeName = "WidgetCreateResponseEnvelope", }, ErrorMap = new ErrorMapPlan @@ -350,6 +356,48 @@ private static OperationPlan CreateWidgetCreateOperation() => Description = null, }; + private static OperationPlan CreateWidgetRemoveOperation() => + new() + { + MethodName = "RemoveWidgetAsync", + HttpMethod = "delete", + RouteTemplate = "/api/widget/{widgetID}", + RouteContainerName = "Widgets", + RouteMemberName = "RemoveWidget", + Parameters = + [ + new OperationParameterPlan + { + WireName = "widgetID", + Name = "widgetId", + TypeName = "string", + IsHandleParameter = false, + }, + ], + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetRemoveResponse", + AdapterTypeName = "WidgetRemoveResponseAdapter", + PayloadName = null, + PayloadTypeName = null, + Kind = EnvelopeKind.NoContent, + SuccessStatusCode = 204, + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 400, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + ], + }, + Summary = "Remove one widget", + Description = null, + }; + private static QueryPropertyPlan QueryProperty(string wireName, string propertyName, QueryValueKind kind, bool isInherited = false) => new() diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs index 61ee295f..c22b354e 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs @@ -11,4 +11,7 @@ internal enum EnvelopeKind /// The body carries a data array beside a previous/next cursor object. CursorList = 2, + + /// The success carries no body at all; the response has no payload property. + NoContent = 3, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs index 06b2a0c1..12cc6335 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs @@ -6,16 +6,23 @@ internal sealed record EnvelopePlan public required string AdapterTypeName { get; init; } - public required string PayloadName { get; init; } + /// Gets the payload property name, or for a no-content success. + public required string? PayloadName { get; init; } - /// Gets the payload type name; for cursor lists, the element type of the data array. - public required string PayloadTypeName { get; init; } + /// + /// Gets the payload type name — for cursor lists, the element type of the data + /// array — or for a no-content success. + /// + public required string? PayloadTypeName { get; init; } public required EnvelopeKind Kind { get; init; } + /// Gets the single declared success status the adapter accepts. + public required int SuccessStatusCode { get; init; } + /// /// Gets the internal single-pass deserialization DTO type name for wrapped envelopes, - /// or when the body is the payload itself. + /// or when the body is the payload itself or absent. /// public string? EnvelopeDtoTypeName { get; init; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 4393f417..c32ae172 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -292,7 +292,7 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation return null; } - var success = _operation.Responses.Single(static response => response.StatusCode is 200); + var success = _operation.Responses.Single(static response => response.StatusCode is 200 or 204); var envelope = BindEnvelope(success); var errorMap = BindErrorMap(); var parameters = BindParameters(row); @@ -408,9 +408,9 @@ private void CheckStatusShape() { Refuse("operation must declare exactly one success response"); } - else if (successes[0].StatusCode is not 200) + else if (successes[0].StatusCode is not (200 or 204)) { - Refuse("the success response must use status 200"); + Refuse("the success response must use status 200 or a content-free 204"); } foreach (var response in _operation.Responses @@ -422,6 +422,11 @@ private void CheckStatusShape() private EnvelopePlan? BindEnvelope(SpecResponse success) { + if (success.StatusCode is 204) + { + return BindNoContentEnvelope(success); + } + if (success.ContentType is not { IsJson: true } || success.Schema is null) { Refuse("the success response must carry a JSON schema"); @@ -485,10 +490,32 @@ SpecEnvelopeShape.Bare or SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocati PayloadName = payloadName, PayloadTypeName = payload, Kind = kind, + SuccessStatusCode = 200, EnvelopeDtoTypeName = kind is EnvelopeKind.Bare ? null : $"{responseTypeName}Envelope", }; } + private EnvelopePlan? BindNoContentEnvelope(SpecResponse success) + { + if (success.ContentType is not null || success.Schema is not null) + { + Refuse("a 204 success must not carry content"); + return null; + } + + var responseTypeName = OperationNamePolicy.ResponseTypeName(_operation); + return new EnvelopePlan + { + ResponseTypeName = responseTypeName, + AdapterTypeName = $"{responseTypeName}Adapter", + PayloadName = null, + PayloadTypeName = null, + Kind = EnvelopeKind.NoContent, + SuccessStatusCode = 204, + EnvelopeDtoTypeName = null, + }; + } + private string? BindBarePayload(SchemaNode schema) { return schema is RefNode reference && _typeNames.TryGetValue(reference.Target, out var name) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs index dca95a9e..ca0728c7 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs @@ -28,7 +28,9 @@ .. clients private static GeneratedSource EmitDto(EnvelopePlan envelope) { - var data = EmitProperty("data", "Data", EmitDataType(envelope)); + var payloadTypeName = envelope.PayloadTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + var data = EmitProperty("data", "Data", EmitDataType(envelope.Kind, payloadTypeName)); if (envelope.Kind is EnvelopeKind.CursorList) { // The page's element schema admits no null; the converter turns a null array or a @@ -37,7 +39,7 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) "JsonConverter", SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.Generic( "NullElementRejectingListJsonConverter", - TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)))))); + TypeSyntaxEmitter.EmitNamed(payloadTypeName)))))); } var members = new List { data, }; @@ -64,11 +66,11 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) return EmissionSyntax.CreateSource($"Internal/Serialization/{envelope.EnvelopeDtoTypeName}.cs", unit); } - private static TypeSyntax EmitDataType(EnvelopePlan envelope) => envelope.Kind switch + private static TypeSyntax EmitDataType(EnvelopeKind kind, string payloadTypeName) => kind switch { - EnvelopeKind.Data => TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName), - EnvelopeKind.CursorList => TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)), - EnvelopeKind.Bare or _ => throw new InvalidOperationException($"Envelope kind '{envelope.Kind}' has no DTO."), + EnvelopeKind.Data => TypeSyntaxEmitter.EmitNamed(payloadTypeName), + EnvelopeKind.CursorList => TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(payloadTypeName)), + EnvelopeKind.Bare or EnvelopeKind.NoContent or _ => throw new InvalidOperationException($"Envelope kind '{kind}' has no DTO."), }; private static PropertyDeclarationSyntax EmitProperty(string wireName, string name, TypeSyntax type) => diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index 325e168c..5cab7583 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -24,7 +24,42 @@ .. clients private static GeneratedSource EmitEnvelope(OperationPlan operation) { var envelope = operation.Envelope; - var fieldName = $"_{CSharpNamePolicy.ToCamelCase(envelope.PayloadName)}"; + var members = envelope.Kind is EnvelopeKind.NoContent + ? EmitNoContentMembers(envelope) + : EmitPayloadMembers(envelope); + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), envelope.ResponseTypeName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("OpenCodeResponse"))))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithMembers(SyntaxFactory.List(members)) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Represents the response of the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' operation.")); + IReadOnlyList usings = envelope.Kind switch + { + EnvelopeKind.NoContent => ["System.Diagnostics.CodeAnalysis", "OpenCode.Sdk.Models"], + EnvelopeKind.CursorList => + ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Internal.Serialization", "OpenCode.Sdk.Models"], + EnvelopeKind.Bare or EnvelopeKind.Data or _ => + ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"], + }; + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); + return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{envelope.ResponseTypeName}.cs", unit); + } + + private static List EmitNoContentMembers(EnvelopePlan envelope) => + [ + EmitSuccessConstructor(envelope.ResponseTypeName), + EmitErrorConstructor(envelope), + ]; + + private static List EmitPayloadMembers(EnvelopePlan envelope) + { + var payloadName = RequirePayloadName(envelope); + var fieldName = $"_{CSharpNamePolicy.ToCamelCase(payloadName)}"; var payloadType = PayloadType(envelope); var members = new List { @@ -43,28 +78,22 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) members.Add(EmitCursorProperty()); } - members.Add(EmitPrintMembers(envelope.PayloadName, fieldName)); - var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), envelope.ResponseTypeName) - .WithModifiers(SyntaxFactory.TokenList( - SyntaxFactory.Token(SyntaxKind.PublicKeyword), - SyntaxFactory.Token(SyntaxKind.SealedKeyword))) - .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("OpenCodeResponse"))))) - .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) - .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) - .WithMembers(SyntaxFactory.List(members)) - .WithLeadingTrivia(EmissionSyntax.Documentation( - $"Represents the response of the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' operation.")); - IReadOnlyList usings = envelope.Kind is EnvelopeKind.CursorList - ? ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Internal.Serialization", "OpenCode.Sdk.Models"] - : ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"]; - var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); - return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{envelope.ResponseTypeName}.cs", unit); + members.Add(EmitPrintMembers(payloadName, fieldName)); + return members; } - private static TypeSyntax PayloadType(EnvelopePlan envelope) => envelope.Kind is EnvelopeKind.CursorList - ? TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)) - : TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName); + private static string RequirePayloadName(EnvelopePlan envelope) => + envelope.PayloadName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + + private static TypeSyntax PayloadType(EnvelopePlan envelope) + { + var payloadTypeName = envelope.PayloadTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + return envelope.Kind is EnvelopeKind.CursorList + ? TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(payloadTypeName)) + : TypeSyntaxEmitter.EmitNamed(payloadTypeName); + } private static FieldDeclarationSyntax EmitBackingField(string fieldName, TypeSyntax payloadType) => SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration( @@ -85,18 +114,23 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en { var responseTypeName = envelope.ResponseTypeName; - // The payload assignment is the SDK's single null-forgiveness: the guarded getter - // makes the null unobservable. var assignments = new List { Assign("Status", SyntaxFactory.IdentifierName("status")), Assign("IsError", SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)), Assign("Error", SyntaxFactory.IdentifierName("error")), Assign("RawBody", SyntaxFactory.IdentifierName("rawBody")), - Assign(envelope.PayloadName, SyntaxFactory.PostfixUnaryExpression( - SyntaxKind.SuppressNullableWarningExpression, - SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), }; + + // The payload assignment is the SDK's single null-forgiveness: the guarded getter + // makes the null unobservable. A no-content envelope has no payload to forgive. + if (envelope.PayloadName is not null) + { + assignments.Add(Assign(envelope.PayloadName, SyntaxFactory.PostfixUnaryExpression( + SyntaxKind.SuppressNullableWarningExpression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); + } + if (envelope.Kind is EnvelopeKind.CursorList) { assignments.Add(Assign("Cursor", SyntaxFactory.PostfixUnaryExpression( @@ -122,6 +156,7 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelope, TypeSyntax payloadType, string fieldName) { + var payloadName = RequirePayloadName(envelope); ExpressionSyntax initValue = SyntaxFactory.IdentifierName("value"); if (envelope.Kind is EnvelopeKind.CursorList) { @@ -129,10 +164,10 @@ private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelo } return EmitGuardedProperty( payloadType, - envelope.PayloadName, + payloadName, fieldName, initValue, - $"Gets the {envelope.PayloadName} payload; guarded on the error path."); + $"Gets the {payloadName} payload; guarded on the error path."); } private static PropertyDeclarationSyntax EmitCursorProperty() => diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs index 8e279258..f04f5e79 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs @@ -93,7 +93,7 @@ private static MethodDeclarationSyntax EmitAdapt(OperationPlan operation) var arms = new List { SyntaxFactory.SwitchExpressionArm( - SyntaxFactory.ConstantPattern(Number(200)), + SyntaxFactory.ConstantPattern(Number(envelope.SuccessStatusCode)), EmitSuccessCreation(envelope)), // Any other 2xx is outside the declared contract: a protocol failure, never an API error. SyntaxFactory.SwitchExpressionArm( @@ -133,27 +133,56 @@ private static MethodDeclarationSyntax EmitAdapt(OperationPlan operation) /// /// Every success path is one deserialization pass: bare bodies read the payload type, - /// wrapped bodies read their internal envelope DTO and project its members. + /// wrapped bodies read their internal envelope DTO and project its members, and a + /// no-content success refuses any body outright. /// private static ExpressionSyntax EmitSuccessCreation(EnvelopePlan envelope) { - var read = EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName("ReadBarePayload"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("rawBody")), - SyntaxFactory.Argument(EmissionSyntax.MemberAccess( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), - envelope.EnvelopeDtoTypeName ?? envelope.PayloadTypeName))); + ExpressionSyntax Read() + { + var readTypeName = envelope.EnvelopeDtoTypeName + ?? envelope.PayloadTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + return EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("ReadBarePayload"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("rawBody")), + SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), + readTypeName))); + } + return envelope.Kind switch { - EnvelopeKind.Bare => EmitSuccessInitializer(envelope, read), - EnvelopeKind.Data => EmitSuccessInitializer(envelope, EmissionSyntax.MemberAccess(read, "Data")), + EnvelopeKind.Bare => EmitSuccessInitializer(envelope, Read()), + EnvelopeKind.Data => EmitSuccessInitializer(envelope, EmissionSyntax.MemberAccess(Read(), "Data")), + EnvelopeKind.NoContent => EmitNoContentSuccess(envelope), EnvelopeKind.CursorList or _ => EmissionSyntax.Invocation( SyntaxFactory.IdentifierName("CreateSuccess"), SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status")), - SyntaxFactory.Argument(read)), + SyntaxFactory.Argument(Read())), }; } + /// A declared no-content success must arrive bodiless; anything else is a protocol failure. + private static ConditionalExpressionSyntax EmitNoContentSuccess(EnvelopePlan envelope) => + SyntaxFactory.ConditionalExpression( + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("rawBody"), "Length"), + SyntaxFactory.ConstantPattern(Number(0))), + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) + .WithInitializer(SyntaxFactory.InitializerExpression( + SyntaxKind.ObjectInitializerExpression, + SyntaxFactory.SeparatedList( + [ + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Status"), + SyntaxFactory.IdentifierName("status")), + ]))), + SyntaxFactory.ThrowExpression(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("NonEmptyNoContentFailure"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status"))))); + private static ObjectCreationExpressionSyntax EmitSuccessInitializer(EnvelopePlan envelope, ExpressionSyntax payload) => SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) .WithInitializer(SyntaxFactory.InitializerExpression( @@ -166,10 +195,14 @@ private static ObjectCreationExpressionSyntax EmitSuccessInitializer(EnvelopePla SyntaxFactory.IdentifierName("status")), SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName(envelope.PayloadName), + SyntaxFactory.IdentifierName(RequirePayloadName(envelope)), payload), ]))); + private static string RequirePayloadName(EnvelopePlan envelope) => + envelope.PayloadName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); + /// Cursor lists project the DTO twice, so a private helper keeps the switch arm single-read. private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan envelope) => SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName), "CreateSuccess") @@ -195,7 +228,7 @@ private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan enve SyntaxFactory.IdentifierName("status")), SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName(envelope.PayloadName), + SyntaxFactory.IdentifierName(RequirePayloadName(envelope)), EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Data")), SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, From 3511ebbccce050ad7005c4babc705956c629310f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 22:39:58 +0300 Subject: [PATCH 02/13] feat(tools): bind and emit the location-echo envelope profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The {location,data} wrapper — classified at ingestion since the census — now binds fail-closed in object and list variants: the wrapper joins the excluded response roots, the required location sibling must reference a named component schema (promoted inline refused, mirroring the cursor-list item wall), and the envelope plan records the sibling type. Envelopes surface a guarded Location property beside the payload, DTOs read both members in one pass, adapters project through the generalized sibling helper, and 'Location' joins the reserved payload names. --- .../Binding/OperationPlanBinderTests.cs | 75 ++++++++- ...ce_Virtual_Delegating_Clients.verified.txt | 26 ++++ ...ould_Render_The_Envelope_Dtos.verified.txt | 39 +++++ ...ce_Guarded_Response_Envelopes.verified.txt | 137 +++++++++++++++++ ...ce_Generated_Context_Registry.verified.txt | 3 + ...roduce_Status_Mapped_Adapters.verified.txt | 84 ++++++++++ ...roduce_Escaped_Route_Builders.verified.txt | 8 + .../Support/EmitterPlanFixture.cs | 76 ++++++++- .../Generator/Binding/Models/EnvelopeKind.cs | 6 + .../Generator/Binding/Models/EnvelopePlan.cs | 6 + .../Generator/Binding/OperationPlanBinder.cs | 144 +++++++++++++++--- .../Binding/ReachableSchemaCollector.cs | 3 +- .../Generator/Emission/EnvelopeDtoEmitter.cs | 14 +- .../Generator/Emission/EnvelopeEmitter.cs | 33 +++- .../Emission/ResponseAdapterEmitter.cs | 32 ++-- 15 files changed, 647 insertions(+), 39 deletions(-) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index dfc61685..07fdb938 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -448,6 +448,65 @@ await Assert.That(plan.Registry.TypeNames .SequenceEqual(["WidgetInfo", "WidgetListResponseEnvelope"], StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Bind_Should_Bind_A_Data_Location_Envelope() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario()); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocation); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(list.Envelope.LocationTypeName).IsEqualTo("PlaceInfo"); + await Assert.That(list.Envelope.EnvelopeDtoTypeName).IsEqualTo("WidgetListResponseEnvelope"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["PlaceInfo", "WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Registry.TypeNames.Contains("WidgetListResponseEnvelope", StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Bind_A_Data_Location_List_Envelope() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario( + data: static property => property.Type("array").Items(static item => item.Ref("WidgetInfo")))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocationList); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(list.Envelope.LocationTypeName).IsEqualTo("PlaceInfo"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Data_Location_Envelope_With_An_Optional_Location() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario(locationRequired: false)); + + await AssertWidgetRefusalAsync(document, "require exactly"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Data_Location_Sibling_Without_A_Named_Schema() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario( + location: static property => property.Type("object") + .Property("directory", static inner => inner.Type("string"), required: true))); + + await AssertWidgetRefusalAsync(document, "location sibling"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Data_Location_List_Of_Promoted_Inline_Items() + { + var document = await BindingTestHost.IngestAsync(DataLocationScenario( + data: static property => property.Type("array").Items(static item => item.Type("object") + .Property("id", static inner => inner.Type("string"), required: true)))); + + await AssertWidgetRefusalAsync(document, "named component schema"); + } + [Test] public async Task Bind_Should_Refuse_An_Envelope_Dto_Name_Colliding_With_A_Model() { @@ -809,7 +868,7 @@ public async Task Bind_Should_Refuse_An_Unsupported_Envelope_Shape() .WithOperation("v2.health.get", configure: operation => operation .Response(200, "application/json", schema => schema.Type("object") .Property("data", property => property.Ref("ItemInfo"), required: true) - .Property("location", property => property.Type("string"), required: true))))); + .Property("hasMore", property => property.Type("boolean"), required: true))))); await AssertOperationRefusalAsync(document, "v2.health.get", "envelope shape"); } @@ -1157,6 +1216,20 @@ private static SpecScenario WidgetCreateScenario(Action configure .RequestBody(mediaType, configureBody, required) .Response(200, "application/json", schema => schema.Ref("WidgetInfo")))); + private static SpecScenario DataLocationScenario(Action? data = null, + Action? location = null, bool locationRequired = true) => + SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("PlaceInfo", schema => schema.Type("object") + .Property("directory", property => property.Type("string"), required: true)) + .WithSchema("WidgetEnvelope", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("location", location ?? (static property => property.Ref("PlaceInfo")), required: locationRequired) + .Property("data", data ?? (static property => property.Ref("WidgetInfo")), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetEnvelope")))); + private static SpecScenario NoContentScenario(Action? configure = null) => SpecScenario.Define(spec => spec .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => 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 5340332b..9037537a 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 @@ -265,4 +265,30 @@ public class WidgetsClient ArgumentException.ThrowIfNullOrEmpty(widgetId); return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Widgets.RemoveWidget(widgetId), WidgetRemoveResponseAdapter.Instance, requestOptions, cancellationToken); } + + /// + /// Get one widget spot + /// + /// The per-call options. + /// The cancellation token. + /// The 'WidgetSpotResponse' envelope. + /// The API returned an error status and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task GetWidgetSpotAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetWidgetSpot, WidgetSpotResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List widget spots + /// + /// The per-call options. + /// The cancellation token. + /// The 'WidgetSpotListResponse' envelope. + /// The API returned an error status and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListWidgetSpotsAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListWidgetSpots, WidgetSpotListResponseAdapter.Instance, requestOptions, cancellationToken); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt index 7f0ad3e9..49e81b1a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt @@ -69,3 +69,42 @@ internal sealed record WidgetListResponseEnvelope [JsonPropertyName("cursor")] public required ListCursor Cursor { get; init; } } + +// File: Internal/Serialization/WidgetSpotListResponseEnvelope.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'WidgetSpotListResponse' through one deserialization pass. +/// +internal sealed record WidgetSpotListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("location")] + public required ExamplePlace Location { get; init; } +} + +// File: Internal/Serialization/WidgetSpotResponseEnvelope.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'WidgetSpotResponse' through one deserialization pass. +/// +internal sealed record WidgetSpotResponseEnvelope +{ + [JsonPropertyName("data")] + public required ExampleItem Data { get; init; } + + [JsonPropertyName("location")] + public required ExamplePlace Location { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt index 8a1f6e9a..a2ef08bc 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeEmitterTests.Emit_Should_Produce_Guarded_Response_Envelopes.verified.txt @@ -415,3 +415,140 @@ public sealed record WidgetRemoveResponse : OpenCodeResponse RawBody = rawBody; } } + +// File: Widgets/WidgetSpotListResponse.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/widget-spot-list' operation. +/// +public sealed record WidgetSpotListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _spots; + private readonly ExamplePlace? _location; + /// + /// Initializes a success instance of the 'WidgetSpotListResponse' envelope. + /// + public WidgetSpotListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetSpotListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Spots = null!; + Location = null!; + } + + /// + /// Gets the Spots payload; guarded on the error path. + /// + public required IReadOnlyList Spots { get => _spots ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Spots."); init => _spots = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required ExamplePlace Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_spots is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Spots = ").Append(_spots); + return true; + } +} + +// File: Widgets/WidgetSpotResponse.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/widget-spot' operation. +/// +public sealed record WidgetSpotResponse : OpenCodeResponse +{ + private readonly ExampleItem? _spot; + private readonly ExamplePlace? _location; + /// + /// Initializes a success instance of the 'WidgetSpotResponse' envelope. + /// + public WidgetSpotResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetSpotResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Spot = null!; + Location = null!; + } + + /// + /// Gets the Spot payload; guarded on the error path. + /// + public required ExampleItem Spot { get => _spot ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Spot."); init => _spot = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required ExamplePlace Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_spot is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Spot = ").Append(_spot); + return true; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt index 79b2b032..27ac0c21 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt @@ -12,6 +12,7 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(ExampleEvent))] [JsonSerializable(typeof(ExampleItem))] [JsonSerializable(typeof(ExampleMode))] +[JsonSerializable(typeof(ExamplePlace))] [JsonSerializable(typeof(OpenCodeError))] [JsonSerializable(typeof(UnknownExampleEvent))] [JsonSerializable(typeof(UnknownOpenCodeError))] @@ -20,6 +21,8 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(WidgetItemListResponseEnvelope))] [JsonSerializable(typeof(WidgetItemResponseEnvelope))] [JsonSerializable(typeof(WidgetListResponseEnvelope))] +[JsonSerializable(typeof(WidgetSpotListResponseEnvelope))] +[JsonSerializable(typeof(WidgetSpotResponseEnvelope))] internal sealed partial class OpenCodeJsonContext : JsonSerializerContext { } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt index e958a42f..8a80c3da 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt @@ -293,3 +293,87 @@ internal sealed class WidgetRemoveResponseAdapter : ResponseAdapter new WidgetRemoveResponse(status, ReadTolerantError(rawBody, null), rawBody)}; } } + +// File: Internal/ResponseAdapters/WidgetSpotListResponseAdapter.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/widget-spot-list' responses onto 'WidgetSpotListResponse'. +/// +internal sealed class WidgetSpotListResponseAdapter : ResponseAdapter +{ + private WidgetSpotListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetSpotListResponseAdapter Instance { get; } = new WidgetSpotListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetSpotListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetSpotListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + _ => new WidgetSpotListResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetSpotListResponse CreateSuccess(int status, WidgetSpotListResponseEnvelope envelope) => new WidgetSpotListResponse + { + Status = status, + Spots = envelope.Data, + Location = envelope.Location + }; +} + +// File: Internal/ResponseAdapters/WidgetSpotResponseAdapter.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/widget-spot' responses onto 'WidgetSpotResponse'. +/// +internal sealed class WidgetSpotResponseAdapter : ResponseAdapter +{ + private WidgetSpotResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetSpotResponseAdapter Instance { get; } = new WidgetSpotResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetSpotResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetSpotResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + _ => new WidgetSpotResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetSpotResponse CreateSuccess(int status, WidgetSpotResponseEnvelope envelope) => new WidgetSpotResponse + { + Status = status, + Spot = envelope.Data, + Location = envelope.Location + }; +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt index 1129dfcf..0711c9cb 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt @@ -62,6 +62,10 @@ public static class OpenCodeRoutes /// public const string GetOverview = "/api/widget-overview"; /// + /// The 'GET /api/widget-spot' route. + /// + public const string GetWidgetSpot = "/api/widget-spot"; + /// /// The 'GET /api/widget/{widgetID}/item' route template. /// public const string ListItemsTemplate = "/api/widget/{widgetID}/item"; @@ -92,6 +96,10 @@ public static class OpenCodeRoutes return path + query.Value; } + /// + /// The 'GET /api/widget-spot-list' route. + /// + public const string ListWidgetSpots = "/api/widget-spot-list"; /// /// The 'GET /api/widget' route template. /// diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 7c075c57..9e9c0686 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -10,6 +10,7 @@ public static EmitPlan Create() var models = new ModelPlan[] { CreateExampleItem(), + CreateExamplePlace(), CreateExampleMode(), CreateVariant("CreatedEvent", "created", "item", "Item", Named("ExampleItem")), CreateVariant("DeletedEvent", "deleted", "id", "ID", Named("string")), @@ -38,6 +39,7 @@ public static EmitPlan Create() "ExampleEvent", "ExampleItem", "ExampleMode", + "ExamplePlace", "OpenCodeError", "UnknownExampleEvent", "UnknownOpenCodeError", @@ -46,6 +48,8 @@ public static EmitPlan Create() "WidgetItemListResponseEnvelope", "WidgetItemResponseEnvelope", "WidgetListResponseEnvelope", + "WidgetSpotListResponseEnvelope", + "WidgetSpotResponseEnvelope", ], }, PendingOperations = [], @@ -92,7 +96,15 @@ public static IReadOnlyList CreateClientPlans() => HandleTypeName = "WidgetClient", Parameter = CreateWidgetParameter(), }, - Operations = [CreateOverviewOperation(), CreateWidgetListOperation(), CreateWidgetCreateOperation(), CreateWidgetRemoveOperation()], + Operations = + [ + CreateOverviewOperation(), + CreateWidgetListOperation(), + CreateWidgetCreateOperation(), + CreateWidgetRemoveOperation(), + CreateWidgetSpotOperation(), + CreateWidgetSpotListOperation(), + ], }, ]; @@ -398,6 +410,68 @@ private static OperationPlan CreateWidgetRemoveOperation() => Description = null, }; + private static OperationPlan CreateWidgetSpotOperation() => + new() + { + MethodName = "GetWidgetSpotAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget-spot", + RouteContainerName = "Widgets", + RouteMemberName = "GetWidgetSpot", + Parameters = [], + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetSpotResponse", + AdapterTypeName = "WidgetSpotResponseAdapter", + PayloadName = "Spot", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.DataLocation, + SuccessStatusCode = 200, + EnvelopeDtoTypeName = "WidgetSpotResponseEnvelope", + LocationTypeName = "ExamplePlace", + }, + ErrorMap = new ErrorMapPlan { Statuses = [], }, + Summary = "Get one widget spot", + Description = null, + }; + + private static OperationPlan CreateWidgetSpotListOperation() => + new() + { + MethodName = "ListWidgetSpotsAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget-spot-list", + RouteContainerName = "Widgets", + RouteMemberName = "ListWidgetSpots", + Parameters = [], + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetSpotListResponse", + AdapterTypeName = "WidgetSpotListResponseAdapter", + PayloadName = "Spots", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.DataLocationList, + SuccessStatusCode = 200, + EnvelopeDtoTypeName = "WidgetSpotListResponseEnvelope", + LocationTypeName = "ExamplePlace", + }, + ErrorMap = new ErrorMapPlan { Statuses = [], }, + Summary = "List widget spots", + Description = null, + }; + + private static ObjectModelPlan CreateExamplePlace() => + new() + { + Name = "ExamplePlace", + Namespace = "OpenCode.Sdk.Models", + Description = "Represents the resolved example place.", + Properties = + [ + Property("directory", "Directory", Named("string"), isRequired: true, "Gets the resolved directory."), + ], + }; + private static QueryPropertyPlan QueryProperty(string wireName, string propertyName, QueryValueKind kind, bool isInherited = false) => new() diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs index c22b354e..bdd9d2ff 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs @@ -14,4 +14,10 @@ internal enum EnvelopeKind /// The success carries no body at all; the response has no payload property. NoContent = 3, + + /// The body wraps a data object beside a required location echo. + DataLocation = 4, + + /// The body carries a data array beside a required location echo. + DataLocationList = 5, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs index 12cc6335..365b52be 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs @@ -25,4 +25,10 @@ internal sealed record EnvelopePlan /// or when the body is the payload itself or absent. /// public string? EnvelopeDtoTypeName { get; init; } + + /// + /// Gets the type name of the required location envelope sibling, or + /// when the envelope carries none. + /// + public string? LocationTypeName { get; init; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index c32ae172..a89fbbc9 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -51,6 +51,7 @@ internal sealed class OperationPlanBinder "GetHashCode", "GetType", "IsError", + "Location", "MemberwiseClone", "PrintMembers", "RawBody", @@ -438,15 +439,53 @@ private void CheckStatusShape() SpecEnvelopeShape.Bare => BindBarePayload(success.Schema), SpecEnvelopeShape.Data => BindDataPayload(success.Schema), SpecEnvelopeShape.CursorData => BindCursorListPayload(success.Schema), - SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation or SpecEnvelopeShape.DataHasMore or _ => + SpecEnvelopeShape.DataLocation => BindDataLocationPayload(success.Schema), + SpecEnvelopeShape.None or SpecEnvelopeShape.DataHasMore or _ => RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported"), }; + var locationTypeName = success.EnvelopeShape is SpecEnvelopeShape.DataLocation + ? BindLocationSibling(success.Schema) + : null; + if (success.EnvelopeShape is SpecEnvelopeShape.DataLocation && locationTypeName is null) + { + return null; + } + if (payload is null) { return null; } var responseTypeName = OperationNamePolicy.ResponseTypeName(_operation); + var payloadName = DerivePayloadName(responseTypeName); + if (payloadName is null) + { + return null; + } + + var kind = success.EnvelopeShape switch + { + SpecEnvelopeShape.Data => EnvelopeKind.Data, + SpecEnvelopeShape.CursorData => EnvelopeKind.CursorList, + SpecEnvelopeShape.DataLocation => DataLocationKind(success.Schema), + SpecEnvelopeShape.Bare or SpecEnvelopeShape.None + or SpecEnvelopeShape.DataHasMore or _ => EnvelopeKind.Bare, + }; + return new EnvelopePlan + { + ResponseTypeName = responseTypeName, + AdapterTypeName = $"{responseTypeName}Adapter", + PayloadName = payloadName, + PayloadTypeName = payload, + Kind = kind, + SuccessStatusCode = 200, + EnvelopeDtoTypeName = kind is EnvelopeKind.Bare ? null : $"{responseTypeName}Envelope", + LocationTypeName = locationTypeName, + }; + } + + private string? DerivePayloadName(string responseTypeName) + { var payloadName = _curation.EnvelopePayloadNames.TryGetValue(_operation.OperationId, out var curated) ? curated : OperationNamePolicy.PayloadName(_operation); @@ -476,23 +515,7 @@ private void CheckStatusShape() return null; } - var kind = success.EnvelopeShape switch - { - SpecEnvelopeShape.Data => EnvelopeKind.Data, - SpecEnvelopeShape.CursorData => EnvelopeKind.CursorList, - SpecEnvelopeShape.Bare or SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation - or SpecEnvelopeShape.DataHasMore or _ => EnvelopeKind.Bare, - }; - return new EnvelopePlan - { - ResponseTypeName = responseTypeName, - AdapterTypeName = $"{responseTypeName}Adapter", - PayloadName = payloadName, - PayloadTypeName = payload, - Kind = kind, - SuccessStatusCode = 200, - EnvelopeDtoTypeName = kind is EnvelopeKind.Bare ? null : $"{responseTypeName}Envelope", - }; + return payloadName; } private EnvelopePlan? BindNoContentEnvelope(SpecResponse success) @@ -570,6 +593,84 @@ SpecEnvelopeShape.Bare or SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocati return itemName; } + private string? BindDataLocationPayload(SchemaNode schema) + { + var wrapper = ResolveDataLocationWrapper(schema); + if (wrapper is null) + { + return null; + } + + var data = wrapper.Properties.Single(static property => property.Name is "data"); + if (data.Schema is RefNode datum + && !datum.Target.Contains('#', StringComparison.Ordinal) + && _typeNames.TryGetValue(datum.Target, out var datumName)) + { + return datumName; + } + + // Items must reference top-level components: a promoted inline item would take its + // name from the excluded response root, so the dialect keeps list items nominal. + if (data.Schema is ArrayNode { Item: RefNode item } + && !item.Target.Contains('#', StringComparison.Ordinal) + && _typeNames.TryGetValue(item.Target, out var itemName)) + { + return itemName; + } + + return RefuseNull("location envelope 'data' must reference a named component schema, or be an array of one"); + } + + /// + /// The payload binder owns the wrapper walls; the sibling and kind readers resolve + /// leniently because a malformed wrapper is already refused once. + /// + private ObjectNode? ResolveDataLocationWrapper(SchemaNode schema) + { + if (schema is not RefNode reference + || !_document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode wrapper) + { + return RefuseNull("location envelope must reference an object schema"); + } + + var data = wrapper.Properties.FirstOrDefault(static property => property.Name is "data"); + var location = wrapper.Properties.FirstOrDefault(static property => property.Name is "location"); + if (wrapper.Properties.Count is not 2 || data is not { IsRequired: true } || location is not { IsRequired: true }) + { + return RefuseNull("location envelope must require exactly 'data' and 'location'"); + } + + return wrapper; + } + + private string? BindLocationSibling(SchemaNode schema) + { + if (schema is not RefNode reference + || !_document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode wrapper + || wrapper.Properties.FirstOrDefault(static property => property.Name is "location") is not { IsRequired: true } location) + { + return null; + } + + // A promoted inline sibling would take its name from the excluded response root, + // so the dialect keeps the location echo nominal. + return location.Schema is RefNode sibling + && !sibling.Target.Contains('#', StringComparison.Ordinal) + && _typeNames.TryGetValue(sibling.Target, out var name) + ? name + : RefuseNull("the location sibling must reference a named component schema"); + } + + private EnvelopeKind DataLocationKind(SchemaNode schema) => + schema is RefNode reference + && _document.Schemas.TryGetValue(reference.Target, out var target) + && target is ObjectNode wrapper + && wrapper.Properties.FirstOrDefault(static property => property.Name is "data")?.Schema is ArrayNode + ? EnvelopeKind.DataLocationList + : EnvelopeKind.DataLocation; + /// Recognizes the wire cursor contract: exactly optional-nullable string previous and next. private bool IsListCursorShape(SchemaNode schema) { @@ -912,6 +1013,13 @@ private bool IsParentFilterShape(SchemaNode first, SchemaNode second) return null; } + private T? RefuseNull(string problem) + where T : class + { + Refuse(problem); + return null; + } + private IReadOnlyList? RefuseNullTags(string problem) { Refuse(problem); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs index b455ec48..6f2f3024 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs @@ -57,7 +57,8 @@ public void VisitResponse(string operationId, SpecResponse response) // Data-carrying wrappers are envelope structure, never models — component-named // wrappers join the op-inline roots in the excluded set. var isInlineRoot = reference.Target.StartsWith($"op:{operationId}#", StringComparison.Ordinal); - var isWrapperShape = response.EnvelopeShape is SpecEnvelopeShape.Data or SpecEnvelopeShape.CursorData; + var isWrapperShape = response.EnvelopeShape + is SpecEnvelopeShape.Data or SpecEnvelopeShape.CursorData or SpecEnvelopeShape.DataLocation; if (isInlineRoot || isWrapperShape) { _ = _responseRoots.Add(reference.Target); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs index ca0728c7..aab0ec06 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs @@ -31,7 +31,7 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) var payloadTypeName = envelope.PayloadTypeName ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); var data = EmitProperty("data", "Data", EmitDataType(envelope.Kind, payloadTypeName)); - if (envelope.Kind is EnvelopeKind.CursorList) + if (envelope.Kind is EnvelopeKind.CursorList or EnvelopeKind.DataLocationList) { // The page's element schema admits no null; the converter turns a null array or a // null element into a JsonException the adapters map to the transport wall. @@ -48,6 +48,13 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) members.Add(EmitProperty("cursor", "Cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); } + if (envelope.Kind is EnvelopeKind.DataLocation or EnvelopeKind.DataLocationList) + { + var locationTypeName = envelope.LocationTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no location sibling."); + members.Add(EmitProperty("location", "Location", TypeSyntaxEmitter.EmitNamed(locationTypeName))); + } + var declaration = SyntaxFactory.RecordDeclaration( SyntaxFactory.Token(SyntaxKind.RecordKeyword), envelope.EnvelopeDtoTypeName!) @@ -68,8 +75,9 @@ private static GeneratedSource EmitDto(EnvelopePlan envelope) private static TypeSyntax EmitDataType(EnvelopeKind kind, string payloadTypeName) => kind switch { - EnvelopeKind.Data => TypeSyntaxEmitter.EmitNamed(payloadTypeName), - EnvelopeKind.CursorList => TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(payloadTypeName)), + EnvelopeKind.Data or EnvelopeKind.DataLocation => TypeSyntaxEmitter.EmitNamed(payloadTypeName), + EnvelopeKind.CursorList or EnvelopeKind.DataLocationList => + TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(payloadTypeName)), EnvelopeKind.Bare or EnvelopeKind.NoContent or _ => throw new InvalidOperationException($"Envelope kind '{kind}' has no DTO."), }; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index 5cab7583..14c2ab5f 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -41,9 +41,9 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) IReadOnlyList usings = envelope.Kind switch { EnvelopeKind.NoContent => ["System.Diagnostics.CodeAnalysis", "OpenCode.Sdk.Models"], - EnvelopeKind.CursorList => + EnvelopeKind.CursorList or EnvelopeKind.DataLocationList => ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Internal.Serialization", "OpenCode.Sdk.Models"], - EnvelopeKind.Bare or EnvelopeKind.Data or _ => + EnvelopeKind.Bare or EnvelopeKind.Data or EnvelopeKind.DataLocation or _ => ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"], }; var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); @@ -70,6 +70,11 @@ private static List EmitPayloadMembers(EnvelopePlan env members.Add(EmitBackingField("_cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); } + if (envelope.LocationTypeName is not null) + { + members.Add(EmitBackingField("_location", TypeSyntaxEmitter.EmitNamed(envelope.LocationTypeName))); + } + members.Add(EmitSuccessConstructor(envelope.ResponseTypeName)); members.Add(EmitErrorConstructor(envelope)); members.Add(EmitPayloadProperty(envelope, payloadType, fieldName)); @@ -78,6 +83,11 @@ private static List EmitPayloadMembers(EnvelopePlan env members.Add(EmitCursorProperty()); } + if (envelope.LocationTypeName is not null) + { + members.Add(EmitLocationProperty(envelope.LocationTypeName)); + } + members.Add(EmitPrintMembers(payloadName, fieldName)); return members; } @@ -90,7 +100,7 @@ private static TypeSyntax PayloadType(EnvelopePlan envelope) { var payloadTypeName = envelope.PayloadTypeName ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); - return envelope.Kind is EnvelopeKind.CursorList + return envelope.Kind is EnvelopeKind.CursorList or EnvelopeKind.DataLocationList ? TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(payloadTypeName)) : TypeSyntaxEmitter.EmitNamed(payloadTypeName); } @@ -137,6 +147,13 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en SyntaxKind.SuppressNullableWarningExpression, SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); } + + if (envelope.LocationTypeName is not null) + { + assignments.Add(Assign("Location", SyntaxFactory.PostfixUnaryExpression( + SyntaxKind.SuppressNullableWarningExpression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); + } return SyntaxFactory.ConstructorDeclaration(responseTypeName) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.InternalKeyword))) .AddAttributeLists(EmissionSyntax.Attribute("SetsRequiredMembers")) @@ -158,7 +175,7 @@ private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelo { var payloadName = RequirePayloadName(envelope); ExpressionSyntax initValue = SyntaxFactory.IdentifierName("value"); - if (envelope.Kind is EnvelopeKind.CursorList) + if (envelope.Kind is EnvelopeKind.CursorList or EnvelopeKind.DataLocationList) { initValue = DefensiveListCopy(); } @@ -178,6 +195,14 @@ private static PropertyDeclarationSyntax EmitCursorProperty() => SyntaxFactory.IdentifierName("value"), "Gets the page cursor; guarded on the error path."); + private static PropertyDeclarationSyntax EmitLocationProperty(string locationTypeName) => + EmitGuardedProperty( + TypeSyntaxEmitter.EmitNamed(locationTypeName), + "Location", + "_location", + SyntaxFactory.IdentifierName("value"), + "Gets the location the server resolved for the request; guarded on the error path."); + /// /// List payloads defensively copy on init so the envelope stays immutable; the copy /// refuses null elements the wire contract forbids, while the error path's forgiven diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs index f04f5e79..3fc0e6be 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs @@ -33,7 +33,12 @@ private static GeneratedSource EmitAdapter(OperationPlan operation) members.Add(EmitAdapt(operation)); if (envelope.Kind is EnvelopeKind.CursorList) { - members.Add(EmitCursorSuccessHelper(envelope)); + members.Add(EmitProjectingSuccessHelper(envelope, "Cursor")); + } + + if (envelope.Kind is EnvelopeKind.DataLocation or EnvelopeKind.DataLocationList) + { + members.Add(EmitProjectingSuccessHelper(envelope, "Location")); } var declaration = SyntaxFactory.ClassDeclaration(envelope.AdapterTypeName) .WithModifiers(SyntaxFactory.TokenList( @@ -156,10 +161,11 @@ ExpressionSyntax Read() EnvelopeKind.Bare => EmitSuccessInitializer(envelope, Read()), EnvelopeKind.Data => EmitSuccessInitializer(envelope, EmissionSyntax.MemberAccess(Read(), "Data")), EnvelopeKind.NoContent => EmitNoContentSuccess(envelope), - EnvelopeKind.CursorList or _ => EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName("CreateSuccess"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status")), - SyntaxFactory.Argument(Read())), + EnvelopeKind.CursorList or EnvelopeKind.DataLocation or EnvelopeKind.DataLocationList or _ => + EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("CreateSuccess"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status")), + SyntaxFactory.Argument(Read())), }; } @@ -203,9 +209,12 @@ private static string RequirePayloadName(EnvelopePlan envelope) => envelope.PayloadName ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no payload."); - /// Cursor lists project the DTO twice, so a private helper keeps the switch arm single-read. - private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan envelope) => - SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName), "CreateSuccess") + /// Sibling-carrying envelopes project the DTO twice, so a private helper keeps the switch arm single-read. + private static MethodDeclarationSyntax EmitProjectingSuccessHelper(EnvelopePlan envelope, string siblingName) + { + var dtoTypeName = envelope.EnvelopeDtoTypeName + ?? throw new InvalidOperationException($"Envelope '{envelope.ResponseTypeName}' has no DTO."); + return SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName), "CreateSuccess") .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PrivateKeyword), SyntaxFactory.Token(SyntaxKind.StaticKeyword))) @@ -214,7 +223,7 @@ private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan enve SyntaxFactory.Parameter(SyntaxFactory.Identifier("status")) .WithType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), SyntaxFactory.Parameter(SyntaxFactory.Identifier("envelope")) - .WithType(TypeSyntaxEmitter.EmitNamed(envelope.EnvelopeDtoTypeName!)), + .WithType(TypeSyntaxEmitter.EmitNamed(dtoTypeName)), ]))) .WithExpressionBody(SyntaxFactory.ArrowExpressionClause( SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) @@ -232,10 +241,11 @@ private static MethodDeclarationSyntax EmitCursorSuccessHelper(EnvelopePlan enve EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Data")), SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName("Cursor"), - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Cursor")), + SyntaxFactory.IdentifierName(siblingName), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), siblingName)), ]))))) .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); + } private static ObjectCreationExpressionSyntax EmitErrorCreation(EnvelopePlan envelope, ExpressionSyntax allowedTags) => SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) From ec680525dbe987615c8715accd8f97acae73a461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 22:40:22 +0300 Subject: [PATCH 03/13] docs(plan): check off the envelope-profile increment --- docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md index 327da8ff..229219a1 100644 --- a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md @@ -60,8 +60,8 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi - [ ] Location: request-record property + deepObject route composition; ambient header default in options/pipeline (explicit query wins by server precedence — no client merge logic). -- [ ] Envelope profiles: `{location, data}` and 204-bodyless, with binder walls and - contract tests. +- [x] Envelope profiles: `{location, data}` (object and list variants) and 204-bodyless, + with binder walls and emitter snapshots; contract tests land with admission. - [ ] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE; operation methods + contract tests per op. - [ ] #28 rider end to end. From edf36258d25074c311646798966afca4add0cfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 23:12:53 +0300 Subject: [PATCH 04/13] feat: render the location selector's deepObject query channel (Q94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binder admits exactly one deep-object shape — the optional nullable {directory?, workspace?} selector, matched structurally — as the new Location query kind typed by the hand-written LocationSelector spine record; every other deep-object stays refused. QueryStringBuilder.AddLocation writes the location[directory]/location[workspace] keys with literal brackets matching the first-party client's serialization, route builders emit the call, and query records surface the selector property. PublicApi gains the spine type. --- .../Internal/QueryStringBuilder.cs | 34 +++++++++++ src/OpenCode.Sdk/LocationSelector.cs | 15 +++++ .../QueryStringBuilderTests.cs | 41 +++++++++++++ .../Snapshots/PublicApi.verified.txt | 6 ++ .../Binding/OperationPlanBinderTests.cs | 59 +++++++++++++++++++ ...der_The_Query_Request_Records.verified.txt | 4 ++ ...roduce_Escaped_Route_Builders.verified.txt | 1 + .../Support/EmitterPlanFixture.cs | 1 + .../Binding/Models/QueryValueKind.cs | 3 + .../Generator/Binding/OperationPlanBinder.cs | 46 ++++++++++++++- .../Generator/Emission/QueryRequestEmitter.cs | 1 + .../Generator/Emission/RoutesEmitter.cs | 1 + 12 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 src/OpenCode.Sdk/LocationSelector.cs diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs index 1ed2e4a0..c9ba0e7f 100644 --- a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -63,6 +63,40 @@ public void AddParentFilter(string name, SessionParentFilter? value) } } + public void AddLocation(string name, LocationSelector? value) + { + if (value is null) + { + return; + } + + if (value.Directory is not null) + { + AppendBracketed(name, "directory", value.Directory); + } + + if (value.Workspace is not null) + { + AppendBracketed(name, "workspace", value.Workspace); + } + } + + /// + /// DeepObject keys ride the wire with literal brackets, matching the first-party + /// client's serialization; the member names are SDK literals, never caller input. + /// + private void AppendBracketed(string name, string member, string value) + { + _builder ??= new StringBuilder(); + _ = _builder.Append(_builder.Length is 0 ? '?' : '&') + .Append(Uri.EscapeDataString(name)) + .Append('[') + .Append(member) + .Append(']') + .Append('=') + .Append(Uri.EscapeDataString(value)); + } + private void Append(string name, string value) { _builder ??= new StringBuilder(); diff --git a/src/OpenCode.Sdk/LocationSelector.cs b/src/OpenCode.Sdk/LocationSelector.cs new file mode 100644 index 00000000..c4bff165 --- /dev/null +++ b/src/OpenCode.Sdk/LocationSelector.cs @@ -0,0 +1,15 @@ +namespace OpenCode.Sdk; + +/// +/// Selects the directory and workspace a request addresses; unset members leave the +/// server's own resolution in place. Rides the wire as location[directory] and +/// location[workspace] query keys. +/// +public sealed record LocationSelector +{ + /// Gets the directory the request addresses. + public string? Directory { get; init; } + + /// Gets the workspace the request addresses. + public string? Workspace { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs index 734b5968..11f5376e 100644 --- a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs +++ b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs @@ -116,7 +116,48 @@ public async Task Add_Should_Skip_Every_Null_Optional() query.AddCount("limit", null, "request"); query.AddOrder("order", null); query.AddParentFilter("parentID", null); + query.AddLocation("location", null); await Assert.That(query.Value).IsEqualTo(string.Empty); } + + [Test] + public async Task AddLocation_Should_Write_Both_Members_With_Literal_Brackets() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector { Directory = "/repo", Workspace = "wrk_1" }); + + await Assert.That(query.Value).IsEqualTo("?location[directory]=%2Frepo&location[workspace]=wrk_1"); + } + + [Test] + public async Task AddLocation_Should_Skip_Unset_Members() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector { Workspace = "wrk_1" }); + + await Assert.That(query.Value).IsEqualTo("?location[workspace]=wrk_1"); + } + + [Test] + public async Task AddLocation_Should_Write_Nothing_For_An_Empty_Selector() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector()); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } + + [Test] + public async Task AddLocation_Should_Escape_The_Member_Values() + { + var query = new QueryStringBuilder(); + + query.AddLocation("location", new LocationSelector { Directory = "a b&c=d" }); + + await Assert.That(query.Value).IsEqualTo("?location[directory]=a%20b%26c%3Dd"); + } } diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index f267e10c..5350919d 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -37,6 +37,12 @@ public int? Limit { get; init; } public OpenCode.Sdk.ListOrder? Order { get; init; } } + public sealed class LocationSelector : System.IEquatable + { + public LocationSelector() { } + public string? Directory { get; init; } + public string? Workspace { get; init; } + } public sealed class MessageListRequest : OpenCode.Sdk.ListRequest, System.IEquatable { public MessageListRequest() { } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 07fdb938..e40bc548 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -771,6 +771,40 @@ public async Task Bind_Should_Refuse_A_Deep_Object_Query_Parameter() await AssertWidgetRefusalAsync(document, "deep-object"); } + [Test] + public async Task Bind_Should_Bind_The_Location_Selector_Deep_Object() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableLocationSelector, deepObject: true))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + var property = list.QueryRequest!.Properties.Single(); + await Assert.That(property.Kind).IsEqualTo(QueryValueKind.Location); + await Assert.That(property.PropertyName).IsEqualTo("Location"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Deep_Object_Outside_The_Location_Selector_Shape() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableSelectorWithExtraMember, deepObject: true))); + + await AssertWidgetRefusalAsync(document, "location selector"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Required_Location_Selector() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableLocationSelector, required: true, deepObject: true))); + + await AssertWidgetRefusalAsync(document, "location selector"); + } + [Test] public async Task Bind_Should_Refuse_A_Request_Body_On_A_Get_Operation() { @@ -1273,6 +1307,31 @@ public static void NullableParentFilter(SchemaBuilder schema) => schema.AnyOf( static inner => inner.Type("string").AllOf(static constraint => constraint.Raw("pattern", "\"^wid\"")), static inner => inner.Type("string").Enum("null")), static branch => branch.Type("null")); + + public static void NullableLocationSelector(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("object") + .AdditionalPropertiesFalse() + .Property("directory", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))) + .Property("workspace", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))), + static branch => branch.Type("null")); + + public static void NullableSelectorWithExtraMember(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("object") + .AdditionalPropertiesFalse() + .Property("directory", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))) + .Property("workspace", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))) + .Property("project", static property => property.AnyOf( + static inner => inner.Type("string"), + static inner => inner.Type("null"))), + static branch => branch.Type("null")); } private static SpecScenario GadgetScenario(Action? extend = null, bool parametersReversed = false) => diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt index b962e6df..8c17d588 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt @@ -34,4 +34,8 @@ public sealed record WidgetListRequest /// Gets the 'parentID' query value; the server default applies when unset. /// public SessionParentFilter? ParentId { get; init; } + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt index 0711c9cb..86fa5d80 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt @@ -122,6 +122,7 @@ public static class OpenCodeRoutes query.AddOrder("order", request.Order); query.AddText("cursor", request.Cursor); query.AddParentFilter("parentID", request.ParentId); + query.AddLocation("location", request.Location); return path + query.Value; } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 9e9c0686..7a857571 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -249,6 +249,7 @@ private static OperationPlan CreateWidgetListOperation() => QueryProperty("order", "Order", QueryValueKind.ListOrder), QueryProperty("cursor", "Cursor", QueryValueKind.Text), QueryProperty("parentID", "ParentId", QueryValueKind.SessionParentFilter), + QueryProperty("location", "Location", QueryValueKind.Location), ], }, Envelope = new EnvelopePlan diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs index 0ba45fa9..c7a43ebf 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs @@ -14,4 +14,7 @@ internal enum QueryValueKind /// The parent-session filter whose wire admits an identifier or the literal "null". SessionParentFilter = 3, + + /// The deepObject location selector exposed as the shared LocationSelector spine type. + Location = 4, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index a89fbbc9..cc1fd309 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -18,6 +18,7 @@ internal sealed class OperationPlanBinder "ListCursor", "ListOrder", "ListRequest", + "LocationSelector", "OpenCodeApiException", "OpenCodeClientOptions", "OpenCodeException", @@ -671,6 +672,24 @@ schema is RefNode reference ? EnvelopeKind.DataLocationList : EnvelopeKind.DataLocation; + /// + /// Recognizes the dual-channel location selector structurally — exactly the + /// optional-nullable string directory and workspace members — so the + /// route serializer's fixed member set stays safe. + /// + private bool IsLocationSelectorShape(SchemaNode schema) + { + if (Resolve(schema) is not ObjectNode selector + || selector.Properties.Count is not 2 + || selector.Properties.Select(static property => property.Name).Distinct(StringComparer.Ordinal).Count() is not 2) + { + return false; + } + + return selector.Properties.All(property => property is { IsRequired: false, Name: "directory" or "workspace" } + && Resolve(property.Schema) is NullableNode { Inner: PrimitiveNode { Kind: PrimitiveKind.String } }); + } + /// Recognizes the wire cursor contract: exactly optional-nullable string previous and next. private bool IsListCursorShape(SchemaNode schema) { @@ -858,7 +877,12 @@ private int TemplatePosition(string wireName) { if (parameter.IsDeepObject) { - Refuse($"query parameter '{parameter.Name}' must not use deep-object encoding"); + var location = BindLocationSelector(parameter); + if (location is not null) + { + properties.Add(location); + } + continue; } @@ -914,6 +938,26 @@ private int TemplatePosition(string wireName) }; } + /// The one admitted deep-object encoding is the optional nullable location selector. + private QueryPropertyPlan? BindLocationSelector(SpecParameter parameter) + { + if (parameter.IsRequired + || Resolve(parameter.Schema) is not NullableNode nullable + || !IsLocationSelectorShape(nullable.Inner)) + { + Refuse($"query parameter '{parameter.Name}' uses deep-object encoding outside the optional location selector shape"); + return null; + } + + return new QueryPropertyPlan + { + WireName = parameter.Name, + PropertyName = CSharpNamePolicy.ToPascalCase(parameter.Name), + Kind = QueryValueKind.Location, + IsInherited = false, + }; + } + private RequestBodyPlan? BindRequestBody() { var body = _operation.RequestBody; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs index f4abda1b..f52a9d91 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs @@ -74,6 +74,7 @@ private static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property QueryValueKind.PositiveCount => SyntaxFactory.NullableType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), QueryValueKind.ListOrder => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("ListOrder")), QueryValueKind.SessionParentFilter => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("SessionParentFilter")), + QueryValueKind.Location => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("LocationSelector")), _ => throw new InvalidOperationException($"Query value kind '{kind}' has no property type."), }; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs index d69a31b9..fd7d3a77 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -187,6 +187,7 @@ private static IEnumerable EmitQueryComposition(OperationPlan o QueryValueKind.PositiveCount => "AddCount", QueryValueKind.ListOrder => "AddOrder", QueryValueKind.SessionParentFilter => "AddParentFilter", + QueryValueKind.Location => "AddLocation", _ => throw new InvalidOperationException($"Query value kind '{kind}' has no query-builder method."), }; From 862351fc1f8ff365098a3a24deb1e7eba320cb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 23:22:40 +0300 Subject: [PATCH 05/13] feat(sdk): decorate the ambient location headers from the options snapshot (Q94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCodeClientOptions gains the LocationSelector ambient default; the pipeline snapshots it at construction and rides the x-opencode-directory/x-opencode-workspace middleware channel verbatim — the server resolves any explicit per-request query location first, so no client-side merge exists. The selector refuses blank members at init on both channels, and the shared recorder captures request headers. --- .../2026-08-15-m2-second-breadth-batch.md | 8 ++- src/OpenCode.Sdk/IOpenCodeClientOptions.cs | 3 + src/OpenCode.Sdk/Internal/Pipeline.cs | 15 ++++ src/OpenCode.Sdk/LocationSelector.cs | 45 ++++++++++-- src/OpenCode.Sdk/OpenCodeClientOptions.cs | 9 +++ .../LocationSelectorTests.cs | 31 +++++++++ tests/OpenCode.Sdk.Tests/PipelineTests.cs | 68 ++++++++++++++++++- .../Snapshots/PublicApi.verified.txt | 2 + tests/Shared/RecordedRequest.cs | 3 + tests/Shared/RecordingHttpHandler.cs | 4 ++ 10 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/LocationSelectorTests.cs diff --git a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md index 229219a1..185a20e7 100644 --- a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md @@ -57,9 +57,11 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi - [ ] Placement map in the binder, red-test-first; body+query admission for `shell.create`/`shell.timeout` only through it. -- [ ] Location: request-record property + deepObject route composition; ambient header - default in options/pipeline (explicit query wins by server precedence — no client - merge logic). +- [x] Location: the hand-written `LocationSelector` spine (the wire's query selector is + an inline `{directory?, workspace?}` object, not `Location.Info`, so it follows the + `SessionParentFilter` precedent) with request-record property + deepObject route + composition; ambient header default in options/pipeline (explicit query wins by + server precedence — no client merge logic). - [x] Envelope profiles: `{location, data}` (object and list variants) and 204-bodyless, with binder walls and emitter snapshots; contract tests land with admission. - [ ] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE; diff --git a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs index c00daad3..07dbdf59 100644 --- a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs @@ -15,4 +15,7 @@ public interface IOpenCodeClientOptions /// Gets the HTTP basic-authentication password; sends anonymous requests. public string? Password { get; } + + /// Gets the ambient location every request carries as location headers, or for none. + public LocationSelector? Location { get; } } diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 783772c4..feaa92c0 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -12,6 +12,7 @@ internal sealed class Pipeline : IDisposable private readonly AuthenticationHeaderValue? _authorization; private readonly string _endpointBase; private readonly HttpClient _httpClient; + private readonly LocationSelector? _location; private readonly bool _ownsHttpClient; private readonly ProductInfoHeaderValue _userAgent; private bool _disposed; @@ -49,6 +50,7 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt _httpClient = httpClient; _ownsHttpClient = ownsHttpClient; _endpointBase = EndpointPolicy.Normalize(endpoint); + _location = options.Location; _userAgent = UserAgentPolicy.Resolve(); // The options are read exactly once, here: the pipeline holds an immutable snapshot, @@ -210,6 +212,19 @@ private void Decorate(HttpRequestMessage request) request.Headers.Authorization = _authorization; } + // The ambient location rides the middleware's header channel; the values are user + // paths, so they go on the wire verbatim. The server resolves any explicit + // per-request location query first, so no client-side merge exists. + if (_location?.Directory is { } directory) + { + _ = request.Headers.TryAddWithoutValidation("x-opencode-directory", directory); + } + + if (_location?.Workspace is { } workspace) + { + _ = request.Headers.TryAddWithoutValidation("x-opencode-workspace", workspace); + } + request.Headers.UserAgent.Add(_userAgent); } diff --git a/src/OpenCode.Sdk/LocationSelector.cs b/src/OpenCode.Sdk/LocationSelector.cs index c4bff165..f320bccf 100644 --- a/src/OpenCode.Sdk/LocationSelector.cs +++ b/src/OpenCode.Sdk/LocationSelector.cs @@ -2,14 +2,47 @@ namespace OpenCode.Sdk; /// /// Selects the directory and workspace a request addresses; unset members leave the -/// server's own resolution in place. Rides the wire as location[directory] and -/// location[workspace] query keys. +/// server's own resolution in place. On a request it rides the wire as +/// location[directory] and location[workspace] query keys; as the client +/// options' ambient default it rides the x-opencode-directory and +/// x-opencode-workspace headers, which the server resolves after any query value. /// public sealed record LocationSelector { - /// Gets the directory the request addresses. - public string? Directory { get; init; } + private readonly string? _directory; + private readonly string? _workspace; - /// Gets the workspace the request addresses. - public string? Workspace { get; init; } + /// Gets the directory the request addresses; a blank value is refused. + public string? Directory + { + get => _directory; + init + { + if (value is not null && string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException( + "The location directory cannot be empty or whitespace; leave it null when unset.", + nameof(value)); + } + + _directory = value; + } + } + + /// Gets the workspace the request addresses; a blank value is refused. + public string? Workspace + { + get => _workspace; + init + { + if (value is not null && string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException( + "The location workspace cannot be empty or whitespace; leave it null when unset.", + nameof(value)); + } + + _workspace = value; + } + } } diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index 8ea57320..73970539 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -23,4 +23,13 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions /// construction; the SDK never reads credentials from the environment. /// public string? Password { get; set; } + + /// + /// Gets or sets the ambient location every request carries as + /// x-opencode-directory/x-opencode-workspace headers; + /// leaves the server's own resolution in place. An explicit + /// per-request location rides the query channel and wins — the server resolves query + /// before header. + /// + public LocationSelector? Location { get; set; } } diff --git a/tests/OpenCode.Sdk.Tests/LocationSelectorTests.cs b/tests/OpenCode.Sdk.Tests/LocationSelectorTests.cs new file mode 100644 index 00000000..803aa2af --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/LocationSelectorTests.cs @@ -0,0 +1,31 @@ +namespace OpenCode.Sdk.Tests; + +public sealed class LocationSelectorTests +{ + [Test] + public async Task Members_Should_Stay_Null_When_Unset() + { + var selector = new LocationSelector(); + + await Assert.That(selector.Directory).IsNull(); + await Assert.That(selector.Workspace).IsNull(); + } + + [Test] + [Arguments("")] + [Arguments(" ")] + public async Task Directory_Should_Refuse_A_Blank_Value(string value) + { + _ = await Assert.That(() => _ = new LocationSelector { Directory = value }) + .Throws(); + } + + [Test] + [Arguments("")] + [Arguments(" ")] + public async Task Workspace_Should_Refuse_A_Blank_Value(string value) + { + _ = await Assert.That(() => _ = new LocationSelector { Workspace = value }) + .Throws(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 5c101ea4..366f6fab 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -151,6 +151,70 @@ public async Task ExecuteAsync_Should_Let_The_Explicit_Password_Win_Over_A_Defau await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); } + [Test] + public async Task ExecuteAsync_Should_Decorate_The_Ambient_Location_Headers() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector + { + Directory = "/repo", + Workspace = "wrk_1", + }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var request = handler.Requests.Single(); + await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("/repo"); + await Assert.That(request.Headers["x-opencode-workspace"]).IsEqualTo("wrk_1"); + } + + [Test] + public async Task ExecuteAsync_Should_Omit_An_Unset_Location_Member() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector { Directory = "/repo" }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var request = handler.Requests.Single(); + await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("/repo"); + await Assert.That(request.Headers.ContainsKey("x-opencode-workspace")).IsFalse(); + } + + [Test] + public async Task ExecuteAsync_Should_Omit_The_Location_Headers_Without_An_Ambient_Location() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var request = handler.Requests.Single(); + await Assert.That(request.Headers.ContainsKey("x-opencode-directory")).IsFalse(); + await Assert.That(request.Headers.ContainsKey("x-opencode-workspace")).IsFalse(); + } + + [Test] + public async Task ExecuteAsync_Should_Snapshot_The_Ambient_Location_At_Construction() + { + var options = new OpenCodeClientOptions + { + Endpoint = Endpoint, + Location = new LocationSelector { Directory = "/before" }, + }; + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = Pipeline.Create(httpClient, options); + options.Location = new LocationSelector { Directory = "/after" }; + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]).IsEqualTo("/before"); + } + [Test] public async Task ExecuteAsync_Should_Decorate_The_User_Agent_Per_Request() { @@ -539,12 +603,14 @@ private static Pipeline CreatePipeline( bool ownsHttpClient = false, Uri? endpoint = null, string? password = null, - string? username = null) + string? username = null, + LocationSelector? location = null) { var options = new OpenCodeClientOptions { Endpoint = endpoint ?? Endpoint, Password = password, + Location = location, }; if (username is not null) { diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 5350919d..dedc855d 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -14,6 +14,7 @@ public interface IOpenCodeClientOptions { System.Uri? Endpoint { get; } + OpenCode.Sdk.LocationSelector? Location { get; } string? Password { get; } string Username { get; } } @@ -77,6 +78,7 @@ { public OpenCodeClientOptions() { } public System.Uri? Endpoint { get; set; } + public OpenCode.Sdk.LocationSelector? Location { get; set; } public string? Password { get; set; } public string Username { get; set; } } diff --git a/tests/Shared/RecordedRequest.cs b/tests/Shared/RecordedRequest.cs index 1f9bd271..6e0f6faa 100644 --- a/tests/Shared/RecordedRequest.cs +++ b/tests/Shared/RecordedRequest.cs @@ -6,6 +6,9 @@ internal sealed record RecordedRequest public required HttpMethod Method { get; init; } + /// Gets every request header, values joined with commas. + public required IReadOnlyDictionary Headers { get; init; } + public string? Authorization { get; init; } public string? UserAgent { get; init; } diff --git a/tests/Shared/RecordingHttpHandler.cs b/tests/Shared/RecordingHttpHandler.cs index 9296d312..7a0b05c7 100644 --- a/tests/Shared/RecordingHttpHandler.cs +++ b/tests/Shared/RecordingHttpHandler.cs @@ -33,6 +33,10 @@ protected override async Task SendAsync(HttpRequestMessage { RequestUri = request.RequestUri, Method = request.Method, + Headers = request.Headers.ToDictionary( + static header => header.Key, + static header => string.Join(',', header.Value), + StringComparer.OrdinalIgnoreCase), Authorization = request.Headers.Authorization?.ToString(), UserAgent = request.Headers.UserAgent.Count is 0 ? null : request.Headers.UserAgent.ToString(), ContentType = request.Content?.Headers.ContentType?.ToString(), From e65e20da9c3b809991661d0074f28f873470a4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 23:33:45 +0300 Subject: [PATCH 06/13] feat(tools): merge location queries into the uniform request model (Q93) A body+query operation now binds when every query property is the location selector: the query plan rides the request body model (RidesRequestBody), the body model gains JsonIgnore query-side properties attached at composition, the route builder types itself with the body model and the operation method's one request parameter feeds both the route and the JSON path. Every other mix keeps the deliberate refusal, and standalone query records are untouched. --- .../2026-08-15-m2-second-breadth-batch.md | 7 ++- .../Binding/OperationPlanBinderTests.cs | 23 ++++++++++ .../Generator/Emission/ModelEmitterTests.cs | 2 +- ...ce_Virtual_Delegating_Clients.verified.txt | 2 +- ...ce_Immutable_Annotated_Models.verified.txt | 27 +++++++++++ ...roduce_Escaped_Route_Builders.verified.txt | 23 +++++++++- .../Support/EmitterPlanFixture.cs | 9 ++++ .../Binding/Models/ObjectModelPlan.cs | 14 ++++++ .../Binding/Models/QueryRequestPlan.cs | 6 +++ .../Generator/Binding/OperationPlanBinder.cs | 24 ++++++---- .../Generator/Binding/SpecBinder.cs | 46 ++++++++++++++++++- .../Generator/Emission/ModelEmitter.cs | 20 +++++++- .../Emission/OperationMethodEmitter.cs | 5 +- .../Generator/Emission/QueryRequestEmitter.cs | 4 +- .../Generator/Emission/RoutesEmitter.cs | 6 +++ 15 files changed, 196 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md index 185a20e7..a8a5b465 100644 --- a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md @@ -55,8 +55,11 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi ## Work plan -- [ ] Placement map in the binder, red-test-first; body+query admission for - `shell.create`/`shell.timeout` only through it. +- [x] Placement map in the binder, red-test-first: a body+query mix merges into one + uniform request model exactly when every query property is the location selector + (`QueryRequestPlan.RidesRequestBody`); the model gains `[JsonIgnore]` query-side + properties, the route builder types itself with the body model, and every other + mix keeps the deliberate wall. - [x] Location: the hand-written `LocationSelector` spine (the wire's query selector is an inline `{directory?, workspace?}` object, not `Location.Info`, so it follows the `SessionParentFilter` precedent) with request-record property + deepObject route diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index e40bc548..ebd5ff68 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -762,6 +762,29 @@ public async Task Bind_Should_Refuse_An_Object_Query_Parameter() await AssertWidgetRefusalAsync(document, "unsupported schema shape"); } + [Test] + public async Task Bind_Should_Merge_A_Location_Query_Into_The_Request_Body_Model() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => operation + .Parameter("location", "query", QueryScenarioData.NullableLocationSelector, deepObject: true) + .RequestBody("application/json", body => body.Type("object") + .Property("title", property => property.Type("string"), required: true), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + var plan = BindWidgets(document, "v2.widget.create"); + + var create = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(create.RequestBody!.TypeName).IsEqualTo("WidgetCreateRequest"); + await Assert.That(create.QueryRequest!.RidesRequestBody).IsTrue(); + await Assert.That(create.QueryRequest.TypeName).IsEqualTo("WidgetCreateRequest"); + await Assert.That(create.QueryRequest.Properties.Single().Kind).IsEqualTo(QueryValueKind.Location); + var model = (ObjectModelPlan)plan.Models.Single(static model => model.Name is "WidgetCreateRequest"); + await Assert.That(model.RequestQueryProperties.Single().PropertyName).IsEqualTo("Location"); + } + [Test] public async Task Bind_Should_Refuse_A_Deep_Object_Query_Parameter() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs index 17e45879..ec251014 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs @@ -30,6 +30,6 @@ public async Task Emit_Should_Reject_Wire_Null_Only_Where_The_Schema_Forbids_It( await Assert.That(source).Contains("[JsonConverter(typeof(WireNullRejectingJsonConverter))]"); await Assert.That(source).Contains("[JsonConverter(typeof(WireNullRejectingValueJsonConverter))]"); var occurrences = source.Split("WireNullRejecting").Length - 1; - await Assert.That(occurrences).IsEqualTo(2); + await Assert.That(occurrences).IsEqualTo(3); } } 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 9037537a..03dae63b 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 @@ -248,7 +248,7 @@ public class WidgetsClient /// The server could not be reached or returned a malformed success body. public virtual Task CreateWidgetAsync(WidgetCreateRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Widgets.CreateWidget, request ?? new WidgetCreateRequest(), OpenCodeJsonContext.Default.WidgetCreateRequest, WidgetCreateResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Widgets.CreateWidget(request), request ?? new WidgetCreateRequest(), OpenCodeJsonContext.Default.WidgetCreateRequest, WidgetCreateResponseAdapter.Instance, requestOptions, cancellationToken); } /// diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt index f8a8c691..6306c1d1 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt @@ -104,3 +104,30 @@ public enum ExampleMode [JsonStringEnumMemberName("safe")] Safe } + +// File: Models/WidgetCreateRequest.cs +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Shapes the widget-create request body. +/// +public sealed record WidgetCreateRequest +{ + /// + /// Gets the widget title. + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Title { get; init; } + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt index 86fa5d80..e3814b8c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt @@ -3,6 +3,7 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System; using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -27,9 +28,27 @@ public static class OpenCodeRoutes public static class Widgets { /// - /// The 'POST /api/widget' route. + /// The 'POST /api/widget' route template. /// - public const string CreateWidget = "/api/widget"; + public const string CreateWidgetTemplate = "/api/widget"; + /// + /// Builds the '/api/widget' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string CreateWidget(WidgetCreateRequest? request = null) + { + var path = "/api/widget"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + /// /// The 'GET /api/widget/{widgetID}/item/{itemID}' route template. /// diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 7a857571..7f31fcaf 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -338,6 +338,13 @@ private static OperationPlan CreateWidgetCreateOperation() => RouteContainerName = "Widgets", RouteMemberName = "CreateWidget", Parameters = [], + QueryRequest = new QueryRequestPlan + { + TypeName = "WidgetCreateRequest", + DerivesFromListRequest = false, + RidesRequestBody = true, + Properties = [QueryProperty("location", "Location", QueryValueKind.Location)], + }, RequestBody = new RequestBodyPlan { TypeName = "WidgetCreateRequest", @@ -493,6 +500,7 @@ private static ObjectModelPlan CreateWidgetCreateRequest() => [ Property("title", "Title", Named("string", isNullable: true), isRequired: false, "Gets the widget title."), ], + RequestQueryProperties = [QueryProperty("location", "Location", QueryValueKind.Location)], }; public static EmitPlan CreateModelSnapshot() @@ -505,6 +513,7 @@ public static EmitPlan CreateModelSnapshot() plan.Models.Single(static model => model.Name == "BadRequestError"), plan.Models.Single(static model => model.Name == "ExampleItem"), plan.Models.Single(static model => model.Name == "ExampleMode"), + plan.Models.Single(static model => model.Name == "WidgetCreateRequest"), ], Unions = [plan.Unions.Single(static union => union.Name == "OpenCodeError")], }; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs index 7cd90e7d..6ae7608e 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs @@ -13,4 +13,18 @@ public required IReadOnlyList Properties } = Array.AsReadOnly(Array.Empty()); public string? BaseTypeName { get; init; } + + /// + /// Gets the query-side properties a merged operation request carries beside its body + /// properties; they never serialize and the route builder consumes them instead. + /// + public IReadOnlyList RequestQueryProperties + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs index 606637d2..3e0b4fec 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs @@ -7,6 +7,12 @@ internal sealed record QueryRequestPlan /// Gets a value indicating whether the record derives from the ListRequest base. public required bool DerivesFromListRequest { get; init; } + /// + /// Gets a value indicating whether the query properties ride the operation's request + /// body model instead of a standalone record; the type names match by construction. + /// + public bool RidesRequestBody { get; init; } + /// Gets every bound query parameter in wire order, including base-inherited ones. public required IReadOnlyList Properties { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index cc1fd309..5d814840 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -237,8 +237,10 @@ private void CheckTypeNameCollisions(List clients, List - operation.Plan.QueryRequest is not null && !owners.Add(operation.Plan.QueryRequest.TypeName))) + operation.Plan.QueryRequest is { RidesRequestBody: false } query && !owners.Add(query.TypeName))) { errors.Add( BindingErrorCategory.Naming, @@ -301,6 +303,18 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation var optionalPlanErrorsBefore = _errors.Count; var queryRequest = BindQueryRequest(); var requestBody = BindRequestBody(); + if (queryRequest is not null && requestBody is not null) + { + // A body and query merge into one uniform request model only for the + // location channel; every other mix keeps the deliberate wall. + if (queryRequest.Properties.Any(static property => property.Kind is not QueryValueKind.Location)) + { + Refuse("operations mixing a request body and query parameters are supported only for the location selector"); + return null; + } + + queryRequest = queryRequest with { RidesRequestBody = true }; + } var methodName = OperationNamePolicy.MethodName(_operation); var routeMemberName = OperationNamePolicy.RouteMemberName(_operation, row.Placement); if (methodName is null || routeMemberName is null) @@ -373,14 +387,6 @@ private bool CheckWireShape() Refuse("POST operations must carry a request body"); } - // Refused independently of any name collision: no admitted operation shape - // carries both a request body and query parameters. - if (_operation.RequestBody is not null - && _operation.Parameters.Any(static parameter => parameter.Location is SpecParameterLocation.Query)) - { - Refuse("operations mixing a request body and query parameters are not supported"); - } - CheckParameterShapes(); CheckStatusShape(); return _errors.Count == before; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 6ef137d3..228baba4 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -62,12 +62,13 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }) .ToArray(); + var models = AttachRequestQueryProperties(schemaResult.Models, clients, errors); CheckDtoNameCollisions(schemaResult.Registry, clients, errors); errors.ThrowIfAny(); return new EmitPlan { SelectedOperationIds = selection.OperationIds, - Models = schemaResult.Models, + Models = models, Unions = schemaResult.Unions, Registry = ComposeRegistry(schemaResult.Registry, clients), Clients = clients, @@ -75,6 +76,49 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }; } + /// + /// A merged operation's query properties ride its body model; the model plan gains + /// them here because schema binding cannot see operation placement. + /// + private static IReadOnlyList AttachRequestQueryProperties(IReadOnlyList models, + IReadOnlyList clients, BindingErrorCollector errors) + { + var merged = clients + .SelectMany(static client => client.Operations) + .Where(static operation => operation.QueryRequest is { RidesRequestBody: true }) + .ToDictionary(static operation => operation.QueryRequest!.TypeName, StringComparer.Ordinal); + if (merged.Count is 0) + { + return models; + } + + var result = new List(models.Count); + foreach (var model in models) + { + if (!merged.TryGetValue(model.Name, out var operation)) + { + result.Add(model); + continue; + } + + _ = merged.Remove(model.Name); + if (model is not ObjectModelPlan objectModel) + { + errors.Add(BindingErrorCategory.Operation, model.Name, "a merged request body must bind to an object model"); + continue; + } + + result.Add(objectModel with { RequestQueryProperties = operation.QueryRequest!.Properties }); + } + + foreach (var missing in merged.Keys.Order(StringComparer.Ordinal)) + { + errors.Add(BindingErrorCategory.Operation, missing, "a merged request body model is absent from the model closure"); + } + + return result; + } + /// /// A DTO sharing a model's name would make the emitted DTO recursively typed through /// same-namespace resolution and collapse two registry identities into one; both diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs index faf0dfd8..395e33db 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs @@ -55,8 +55,12 @@ private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? base SyntaxFactory.Token(SyntaxKind.SealedKeyword))) .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) - .WithMembers(SyntaxFactory.List(model.Properties.Select(property => - EmitProperty(property, chainMarkers.Any(wireName => IsDiscriminator(property, wireName)), valueTypeNames)))) + .WithMembers(SyntaxFactory.List( + [ + .. model.Properties.Select(property => + EmitProperty(property, chainMarkers.Any(wireName => IsDiscriminator(property, wireName)), valueTypeNames)), + .. model.RequestQueryProperties.Select(static property => EmitRequestQueryProperty(property)), + ])) .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Represents a {DisplayName(model.Name)} value.")); if (model.BaseTypeName is not null) { @@ -89,6 +93,18 @@ private static GeneratedSource EmitEnum(EnumModelPlan model) return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit); } + /// A merged request's query-side property never serializes; the route builder reads it. + private static PropertyDeclarationSyntax EmitRequestQueryProperty(QueryPropertyPlan property) + { + // The documentation trivia must stay ahead of the added attribute list. + var declaration = QueryRequestEmitter.EmitProperty(property); + var documentation = declaration.GetLeadingTrivia(); + return declaration + .WithoutLeadingTrivia() + .AddAttributeLists(EmissionSyntax.Attribute("JsonIgnore")) + .WithLeadingTrivia(documentation); + } + private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator, IReadOnlySet valueTypeNames) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs index 4f7526db..f634f956 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs @@ -58,7 +58,8 @@ private static IEnumerable EmitParameters(OperationPlan operati : body.WithType(TypeSyntaxEmitter.EmitNamed(operation.RequestBody.TypeName)); } - if (operation.QueryRequest is not null) + // A merged request already surfaced as the body parameter above. + if (operation.QueryRequest is { RidesRequestBody: false }) { yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("request")) .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.QueryRequest.TypeName))) @@ -155,7 +156,7 @@ private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, IRead : "The request body.")); } - if (operation.QueryRequest is not null) + if (operation.QueryRequest is { RidesRequestBody: false }) { parameters.Add(new DocumentedParameter("request", "The request shaping the query.")); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs index f52a9d91..a6fe2132 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs @@ -19,7 +19,7 @@ public static IReadOnlyList Emit(IReadOnlyList clie [ .. clients .SelectMany(static client => client.Operations) - .Where(static operation => operation.QueryRequest is not null) + .Where(static operation => operation.QueryRequest is { RidesRequestBody: false }) .OrderBy(static operation => operation.QueryRequest!.TypeName, StringComparer.Ordinal) .Select(static operation => EmitQueryRequest(operation)), ]); @@ -52,7 +52,7 @@ .. queryRequest.Properties return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{queryRequest.TypeName}.cs", unit); } - private static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property) + internal static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property) { var accessors = SyntaxFactory.AccessorList(SyntaxFactory.List( [ diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs index fd7d3a77..88fad1b5 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -34,6 +34,12 @@ public static GeneratedSource Emit(IReadOnlyList clients) usings.Add("OpenCode.Sdk.Internal"); } + // A merged request types its route builder with the body model. + if (operations.Any(static operation => operation.QueryRequest is { RidesRequestBody: true })) + { + usings.Add("OpenCode.Sdk.Models"); + } + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); return EmissionSyntax.CreateSource("OpenCodeRoutes.cs", unit); } From b303f9ce72fd6da3952d312a4a4697e5b9444444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 23:59:28 +0300 Subject: [PATCH 07/13] =?UTF-8?q?feat(sdk):=20admit=20the=20second=20bread?= =?UTF-8?q?th=20batch=20=E2=80=94=20session=20lifecycle=20and=20the=20Shel?= =?UTF-8?q?ls=20family?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven operations land on the sealed design-prover surface: RemoveSessionAsync and RenameSessionAsync complete the session lifecycle over the 204 machinery, and the Shells family (list/create/get/remove/timeout) exercises every Arc 2 seal — deepObject location selectors, the merged body+query ShellCreateRequest and ShellTimeoutRequest, location-echo envelopes, and the first PATCH/DELETE verbs (PATCH rides the internal OpenCodeHttpMethod spine, absent downlevel). The verb vocabulary gains remove/rename/timeout, RequestTypeName folds Get like the response name, reachability recollects post-alias so Shell.Info's promoted inline children stay bound, the alias comparer resolves promoted references structurally, Shell.Info1 collapses through the second schemaAliases row, the Shells IDE0130 arbitration glob extends per its own rule, Extensions registers the family the roster test demanded, and the contract matrix covers the new surface. shell.output defers to a later batch pending two mechanisms of its own. --- .editorconfig | 4 +- .../2026-08-15-m2-second-breadth-batch.md | 11 +- .../OpenCodeServiceCollectionExtensions.cs | 1 + src/OpenCode.Sdk/.generated-manifest.json | 34 ++- src/OpenCode.Sdk/.generation-incomplete | 18 +- .../Internal/OpenCodeHttpMethod.cs | 10 + .../SessionRemoveResponseAdapter.cs | 42 ++++ .../SessionRenameResponseAdapter.cs | 42 ++++ .../ShellCreateResponseAdapter.cs | 44 ++++ .../ShellListResponseAdapter.cs | 44 ++++ .../ShellRemoveResponseAdapter.cs | 42 ++++ .../ResponseAdapters/ShellResponseAdapter.cs | 46 ++++ .../ShellTimeoutResponseAdapter.cs | 46 ++++ .../OpenCodeErrorJsonConverter.cs | 1 + .../Serialization/OpenCodeJsonContext.cs | 13 ++ .../ShellCreateResponseEnvelope.cs | 17 ++ .../ShellListResponseEnvelope.cs | 18 ++ .../Serialization/ShellResponseEnvelope.cs | 17 ++ .../ShellTimeoutResponseEnvelope.cs | 17 ++ src/OpenCode.Sdk/Models/LocationInfo.cs | 31 +++ .../Models/LocationInfoProject.cs | 28 +++ .../Models/SessionRenameRequest.cs | 16 ++ src/OpenCode.Sdk/Models/ShellCreateRequest.cs | 45 ++++ src/OpenCode.Sdk/Models/ShellInfo.cs | 85 ++++++++ src/OpenCode.Sdk/Models/ShellInfoStatus.cs | 33 +++ src/OpenCode.Sdk/Models/ShellInfoTime.cs | 25 +++ src/OpenCode.Sdk/Models/ShellNotFoundError.cs | 28 +++ .../Models/ShellTimeoutRequest.cs | 22 ++ src/OpenCode.Sdk/OpenCodeClient.cs | 7 + src/OpenCode.Sdk/OpenCodeRoutes.cs | 178 ++++++++++++++++ src/OpenCode.Sdk/Sessions/SessionClient.cs | 30 +++ .../Sessions/SessionRemoveResponse.cs | 30 +++ .../Sessions/SessionRenameResponse.cs | 30 +++ src/OpenCode.Sdk/Shells/ShellClient.cs | 81 +++++++ .../Shells/ShellCreateResponse.cs | 65 ++++++ src/OpenCode.Sdk/Shells/ShellListRequest.cs | 13 ++ src/OpenCode.Sdk/Shells/ShellListResponse.cs | 66 ++++++ src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs | 13 ++ .../Shells/ShellRemoveResponse.cs | 30 +++ src/OpenCode.Sdk/Shells/ShellRequest.cs | 13 ++ src/OpenCode.Sdk/Shells/ShellResponse.cs | 65 ++++++ .../Shells/ShellTimeoutResponse.cs | 65 ++++++ src/OpenCode.Sdk/Shells/ShellsClient.cs | 74 +++++++ .../Fixtures/Serialization/known-shell.json | 13 ++ .../Sessions/SessionClientContractTests.cs | 32 +++ .../Shells/ShellClientContractTests.cs | 91 ++++++++ .../Shells/ShellsClientContractTests.cs | 100 +++++++++ .../Snapshots/PublicApi.verified.txt | 201 ++++++++++++++++++ .../Support/WireBodyData.cs | 13 ++ .../Binding/OperationNamePolicyTests.cs | 13 ++ .../Binding/OperationPlanBinderTests.cs | 111 +++++++++- .../Generator/Binding/SpecBinderTests.cs | 1 + .../Generator/Binding/CurationValidator.cs | 2 +- .../Generator/Binding/OperationNamePolicy.cs | 7 +- .../Generator/Binding/OperationPlanBinder.cs | 12 +- .../Generator/Binding/SchemaNodeComparer.cs | 146 ++++++++----- .../Generator/Binding/SpecBinder.cs | 36 +--- .../Emission/OperationMethodEmitter.cs | 6 +- tools/curation.json | 11 + tools/generation-profile.txt | 7 + 60 files changed, 2228 insertions(+), 114 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/OpenCodeHttpMethod.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRenameResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/ShellCreateResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/ShellListResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/ShellResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/ShellCreateResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/ShellListResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/ShellResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/ShellTimeoutResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/Models/LocationInfo.cs create mode 100644 src/OpenCode.Sdk/Models/LocationInfoProject.cs create mode 100644 src/OpenCode.Sdk/Models/SessionRenameRequest.cs create mode 100644 src/OpenCode.Sdk/Models/ShellCreateRequest.cs create mode 100644 src/OpenCode.Sdk/Models/ShellInfo.cs create mode 100644 src/OpenCode.Sdk/Models/ShellInfoStatus.cs create mode 100644 src/OpenCode.Sdk/Models/ShellInfoTime.cs create mode 100644 src/OpenCode.Sdk/Models/ShellNotFoundError.cs create mode 100644 src/OpenCode.Sdk/Models/ShellTimeoutRequest.cs create mode 100644 src/OpenCode.Sdk/Sessions/SessionRemoveResponse.cs create mode 100644 src/OpenCode.Sdk/Sessions/SessionRenameResponse.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellClient.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellCreateResponse.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellListRequest.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellListResponse.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellRemoveResponse.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellRequest.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellResponse.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellTimeoutResponse.cs create mode 100644 src/OpenCode.Sdk/Shells/ShellsClient.cs create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-shell.json create mode 100644 tests/OpenCode.Sdk.Tests/Shells/ShellClientContractTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/Shells/ShellsClientContractTests.cs diff --git a/.editorconfig b/.editorconfig index 53ce7662..f4df635c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -804,10 +804,10 @@ dotnet_diagnostic.CA1034.severity = none # test project mirrors the layout with equally flat test namespaces. Winner: the # flat-namespace contract; IDE0130 remains error for every other folder, tools included. # A new family folder extends these globs deliberately - curation changes are API reviews. -[src/OpenCode.Sdk/{Sessions,Health,Pagination}/**.cs] +[src/OpenCode.Sdk/{Sessions,Health,Pagination,Shells}/**.cs] dotnet_diagnostic.IDE0130.severity = none -[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination}/**.cs] +[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination,Shells}/**.cs] dotnet_diagnostic.IDE0130.severity = none # DI extension methods live in the Microsoft.Extensions.DependencyInjection namespace so diff --git a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md index a8a5b465..01024f39 100644 --- a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md @@ -67,8 +67,15 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi server precedence — no client merge logic). - [x] Envelope profiles: `{location, data}` (object and list variants) and 204-bodyless, with binder walls and emitter snapshots; contract tests land with admission. -- [ ] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE; - operation methods + contract tests per op. +- [x] Curation rows + `Shell.Info1` alias; regen; model closure; PATCH/DELETE (PATCH + rides the internal `OpenCodeHttpMethod` spine — `HttpMethod.Patch` is absent + downlevel); operation methods + contract tests per op. The verb vocabulary gained + `remove`/`rename`/`timeout`, `RequestTypeName` folds Get like the response name, + and reachability recollects post-alias so an alias target's promoted inline + children stay bound; the alias comparer resolves promoted references structurally. + **`v2.shell.output` deferred:** its inline data object and integer cursor/limit + query params each need a mechanism no other admitted operation needs; it returns + with a later batch. - [ ] #28 rider end to end. - [ ] Live demo against `opencode2 serve`: shell create → list → get → output → remove, plus session rename → remove. diff --git a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs index c88b17e9..775227b2 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs +++ b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs @@ -49,6 +49,7 @@ private static IServiceCollection AddOpenCodeCore(IServiceCollection services) _ = services.AddSingleton(static provider => new OpenCodeClient( provider.GetRequiredService>().Value)); _ = services.AddSingleton(static SessionsClient (provider) => provider.GetRequiredService().Sessions); + _ = services.AddSingleton(static ShellsClient (provider) => provider.GetRequiredService().Shells); return services; } } diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index f76aae98..d41233ea 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -6,7 +6,14 @@ "Internal/ResponseAdapters/SessionCreateResponseAdapter.cs", "Internal/ResponseAdapters/SessionListResponseAdapter.cs", "Internal/ResponseAdapters/SessionMessageResponseAdapter.cs", + "Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs", + "Internal/ResponseAdapters/SessionRenameResponseAdapter.cs", "Internal/ResponseAdapters/SessionResponseAdapter.cs", + "Internal/ResponseAdapters/ShellCreateResponseAdapter.cs", + "Internal/ResponseAdapters/ShellListResponseAdapter.cs", + "Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs", + "Internal/ResponseAdapters/ShellResponseAdapter.cs", + "Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs", "Internal/Serialization/MessageListResponseEnvelope.cs", "Internal/Serialization/OpenCodeErrorJsonConverter.cs", "Internal/Serialization/OpenCodeJsonContext.cs", @@ -21,6 +28,10 @@ "Internal/Serialization/SessionMessageResponseEnvelope.cs", "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", "Internal/Serialization/SessionResponseEnvelope.cs", + "Internal/Serialization/ShellCreateResponseEnvelope.cs", + "Internal/Serialization/ShellListResponseEnvelope.cs", + "Internal/Serialization/ShellResponseEnvelope.cs", + "Internal/Serialization/ShellTimeoutResponseEnvelope.cs", "Internal/Serialization/ToolContentJsonConverter.cs", "Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs", "Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs", @@ -34,6 +45,8 @@ "Models/FileDiffInfoStatus.cs", "Models/InvalidCursorError.cs", "Models/InvalidRequestError.cs", + "Models/LocationInfo.cs", + "Models/LocationInfoProject.cs", "Models/LocationRef.cs", "Models/MessageNotFoundError.cs", "Models/ModelRef.cs", @@ -100,8 +113,15 @@ "Models/SessionMessageUser.cs", "Models/SessionMessageUserTime.cs", "Models/SessionNotFoundError.cs", + "Models/SessionRenameRequest.cs", "Models/SessionRevert.cs", "Models/SessionStructuredError.cs", + "Models/ShellCreateRequest.cs", + "Models/ShellInfo.cs", + "Models/ShellInfoStatus.cs", + "Models/ShellInfoTime.cs", + "Models/ShellNotFoundError.cs", + "Models/ShellTimeoutRequest.cs", "Models/TokenUsageInfo.cs", "Models/TokenUsageInfoCache.cs", "Models/ToolContent.cs", @@ -126,7 +146,19 @@ "Sessions/SessionListRequest.cs", "Sessions/SessionListResponse.cs", "Sessions/SessionMessageResponse.cs", + "Sessions/SessionRemoveResponse.cs", + "Sessions/SessionRenameResponse.cs", "Sessions/SessionResponse.cs", - "Sessions/SessionsClient.cs" + "Sessions/SessionsClient.cs", + "Shells/ShellClient.cs", + "Shells/ShellCreateResponse.cs", + "Shells/ShellListRequest.cs", + "Shells/ShellListResponse.cs", + "Shells/ShellRemoveRequest.cs", + "Shells/ShellRemoveResponse.cs", + "Shells/ShellRequest.cs", + "Shells/ShellResponse.cs", + "Shells/ShellTimeoutResponse.cs", + "Shells/ShellsClient.cs" ] } diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete index a10db037..c302326d 100644 --- a/src/OpenCode.Sdk/.generation-incomplete +++ b/src/OpenCode.Sdk/.generation-incomplete @@ -1,6 +1,6 @@ Generation is incomplete; packages must not be published. -Selected operations: 6 -Pending operations: 114 +Selected operations: 13 +Pending operations: 107 Selected: - v2.health.get - v2.message.list @@ -8,6 +8,13 @@ Selected: - v2.session.get - v2.session.list - v2.session.message +- v2.session.remove +- v2.session.rename +- v2.shell.create +- v2.shell.get +- v2.shell.list +- v2.shell.remove +- v2.shell.timeout Pending: - v2.agent.get - v2.agent.list @@ -100,8 +107,6 @@ Pending: - v2.session.question.list - v2.session.question.reject - v2.session.question.reply -- v2.session.remove -- v2.session.rename - v2.session.revert.clear - v2.session.revert.commit - v2.session.revert.stage @@ -111,12 +116,7 @@ Pending: - v2.session.switchModel - v2.session.synthetic - v2.session.wait -- v2.shell.create -- v2.shell.get -- v2.shell.list - v2.shell.output -- v2.shell.remove -- v2.shell.timeout - v2.skill.list - v2.vcs.diff - v2.vcs.get diff --git a/src/OpenCode.Sdk/Internal/OpenCodeHttpMethod.cs b/src/OpenCode.Sdk/Internal/OpenCodeHttpMethod.cs new file mode 100644 index 00000000..c37df1db --- /dev/null +++ b/src/OpenCode.Sdk/Internal/OpenCodeHttpMethod.cs @@ -0,0 +1,10 @@ +namespace OpenCode.Sdk.Internal; + +/// +/// Verb singletons the downlevel BCL does not expose; HttpMethod.Patch only exists +/// on modern targets, so generated methods reference this spine instead. +/// +internal static class OpenCodeHttpMethod +{ + public static HttpMethod Patch { get; } = new HttpMethod("PATCH"); +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs new file mode 100644 index 00000000..ba6653b4 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRemoveResponseAdapter.cs @@ -0,0 +1,42 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'DELETE /api/session/{sessionID}' responses onto 'SessionRemoveResponse'. +/// +internal sealed class SessionRemoveResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private SessionRemoveResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionRemoveResponseAdapter Instance { get; } = new SessionRemoveResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionRemoveResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new SessionRemoveResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionRemoveResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionRemoveResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new SessionRemoveResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new SessionRemoveResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRenameResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRenameResponseAdapter.cs new file mode 100644 index 00000000..a873350e --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionRenameResponseAdapter.cs @@ -0,0 +1,42 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'POST /api/session/{sessionID}/rename' responses onto 'SessionRenameResponse'. +/// +internal sealed class SessionRenameResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private SessionRenameResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionRenameResponseAdapter Instance { get; } = new SessionRenameResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionRenameResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new SessionRenameResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionRenameResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionRenameResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new SessionRenameResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new SessionRenameResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellCreateResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellCreateResponseAdapter.cs new file mode 100644 index 00000000..f41edfb0 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellCreateResponseAdapter.cs @@ -0,0 +1,44 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'POST /api/shell' responses onto 'ShellCreateResponse'. +/// +internal sealed class ShellCreateResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private ShellCreateResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellCreateResponseAdapter Instance { get; } = new ShellCreateResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellCreateResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellCreateResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellCreateResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellCreateResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new ShellCreateResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellCreateResponse CreateSuccess(int status, ShellCreateResponseEnvelope envelope) => new() + { + Status = status, + Shell = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellListResponseAdapter.cs new file mode 100644 index 00000000..4b60af88 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellListResponseAdapter.cs @@ -0,0 +1,44 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/shell' responses onto 'ShellListResponse'. +/// +internal sealed class ShellListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private ShellListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellListResponseAdapter Instance { get; } = new ShellListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellListResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new ShellListResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellListResponse CreateSuccess(int status, ShellListResponseEnvelope envelope) => new() + { + Status = status, + Shells = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs new file mode 100644 index 00000000..d3f7c98c --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellRemoveResponseAdapter.cs @@ -0,0 +1,42 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'DELETE /api/shell/{id}' responses onto 'ShellRemoveResponse'. +/// +internal sealed class ShellRemoveResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["ShellNotFoundError"]; + private ShellRemoveResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellRemoveResponseAdapter Instance { get; } = new ShellRemoveResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellRemoveResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 204 => rawBody.Length is 0 ? new ShellRemoveResponse + { + Status = status + } + + : throw NonEmptyNoContentFailure(status), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellRemoveResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellRemoveResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new ShellRemoveResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new ShellRemoveResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellResponseAdapter.cs new file mode 100644 index 00000000..9f361fad --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellResponseAdapter.cs @@ -0,0 +1,46 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'GET /api/shell/{id}' responses onto 'ShellResponse'. +/// +internal sealed class ShellResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["ShellNotFoundError"]; + private ShellResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellResponseAdapter Instance { get; } = new ShellResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new ShellResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new ShellResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellResponse CreateSuccess(int status, ShellResponseEnvelope envelope) => new() + { + Status = status, + Shell = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs new file mode 100644 index 00000000..2e2e14a4 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/ShellTimeoutResponseAdapter.cs @@ -0,0 +1,46 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Internal.ResponseAdapters; +/// +/// Adapts the 'PATCH /api/shell/{id}/timeout' responses onto 'ShellTimeoutResponse'. +/// +internal sealed class ShellTimeoutResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["ShellNotFoundError"]; + private ShellTimeoutResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static ShellTimeoutResponseAdapter Instance { get; } = new ShellTimeoutResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override ShellTimeoutResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.ShellTimeoutResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new ShellTimeoutResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static ShellTimeoutResponse CreateSuccess(int status, ShellTimeoutResponseEnvelope envelope) => new() + { + Status = status, + Shell = envelope.Data, + Location = envelope.Location + }; +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs index b6c23dc5..d63d28a8 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs @@ -14,6 +14,7 @@ internal sealed class OpenCodeErrorJsonConverter : JsonConverter ["InvalidRequestError"] = typeof(InvalidRequestError), ["MessageNotFoundError"] = typeof(MessageNotFoundError), ["SessionNotFoundError"] = typeof(SessionNotFoundError), + ["ShellNotFoundError"] = typeof(ShellNotFoundError), ["UnauthorizedError"] = typeof(UnauthorizedError), ["UnknownError"] = typeof(UnknownError) }; diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs index 50eb1b11..0c838e55 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs @@ -10,6 +10,8 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(FileDiffInfoStatus))] [JsonSerializable(typeof(InvalidCursorError))] [JsonSerializable(typeof(InvalidRequestError))] +[JsonSerializable(typeof(LocationInfo))] +[JsonSerializable(typeof(LocationInfoProject))] [JsonSerializable(typeof(LocationRef))] [JsonSerializable(typeof(MessageListResponseEnvelope))] [JsonSerializable(typeof(MessageNotFoundError))] @@ -80,9 +82,20 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(SessionMessageUser))] [JsonSerializable(typeof(SessionMessageUserTime))] [JsonSerializable(typeof(SessionNotFoundError))] +[JsonSerializable(typeof(SessionRenameRequest))] [JsonSerializable(typeof(SessionResponseEnvelope))] [JsonSerializable(typeof(SessionRevert))] [JsonSerializable(typeof(SessionStructuredError))] +[JsonSerializable(typeof(ShellCreateRequest))] +[JsonSerializable(typeof(ShellCreateResponseEnvelope))] +[JsonSerializable(typeof(ShellInfo))] +[JsonSerializable(typeof(ShellInfoStatus))] +[JsonSerializable(typeof(ShellInfoTime))] +[JsonSerializable(typeof(ShellListResponseEnvelope))] +[JsonSerializable(typeof(ShellNotFoundError))] +[JsonSerializable(typeof(ShellResponseEnvelope))] +[JsonSerializable(typeof(ShellTimeoutRequest))] +[JsonSerializable(typeof(ShellTimeoutResponseEnvelope))] [JsonSerializable(typeof(TokenUsageInfo))] [JsonSerializable(typeof(TokenUsageInfoCache))] [JsonSerializable(typeof(ToolContent))] diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellCreateResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellCreateResponseEnvelope.cs new file mode 100644 index 00000000..395cda16 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellCreateResponseEnvelope.cs @@ -0,0 +1,17 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellCreateResponse' through one deserialization pass. +/// +internal sealed record ShellCreateResponseEnvelope +{ + [JsonPropertyName("data")] + public required ShellInfo Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellListResponseEnvelope.cs new file mode 100644 index 00000000..57512779 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellListResponseEnvelope.cs @@ -0,0 +1,18 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellListResponse' through one deserialization pass. +/// +internal sealed record ShellListResponseEnvelope +{ + [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellResponseEnvelope.cs new file mode 100644 index 00000000..abd400d3 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellResponseEnvelope.cs @@ -0,0 +1,17 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellResponse' through one deserialization pass. +/// +internal sealed record ShellResponseEnvelope +{ + [JsonPropertyName("data")] + public required ShellInfo Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/ShellTimeoutResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/ShellTimeoutResponseEnvelope.cs new file mode 100644 index 00000000..45cb1830 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ShellTimeoutResponseEnvelope.cs @@ -0,0 +1,17 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +/// +/// Carries the wire body of 'ShellTimeoutResponse' through one deserialization pass. +/// +internal sealed record ShellTimeoutResponseEnvelope +{ + [JsonPropertyName("data")] + public required ShellInfo Data { get; init; } + + [JsonPropertyName("location")] + public required LocationInfo Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/LocationInfo.cs b/src/OpenCode.Sdk/Models/LocationInfo.cs new file mode 100644 index 00000000..8c985c74 --- /dev/null +++ b/src/OpenCode.Sdk/Models/LocationInfo.cs @@ -0,0 +1,31 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a location info value. +/// +public sealed record LocationInfo +{ + /// + /// Gets the directory value. + /// + [JsonPropertyName("directory")] + public required string Directory { get; init; } + + /// + /// Gets the workspace id value. + /// + [JsonPropertyName("workspaceID")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? WorkspaceId { get; init; } + + /// + /// Gets the project value. + /// + [JsonPropertyName("project")] + public required LocationInfoProject Project { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/LocationInfoProject.cs b/src/OpenCode.Sdk/Models/LocationInfoProject.cs new file mode 100644 index 00000000..dc15d045 --- /dev/null +++ b/src/OpenCode.Sdk/Models/LocationInfoProject.cs @@ -0,0 +1,28 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a location info project value. +/// +public sealed record LocationInfoProject +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the directory value. + /// + [JsonPropertyName("directory")] + public required string Directory { get; init; } + + /// + /// Gets the canonical value. + /// + [JsonPropertyName("canonical")] + public required string Canonical { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionRenameRequest.cs b/src/OpenCode.Sdk/Models/SessionRenameRequest.cs new file mode 100644 index 00000000..1d5ae60b --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionRenameRequest.cs @@ -0,0 +1,16 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session rename request value. +/// +public sealed record SessionRenameRequest +{ + /// + /// Gets the title value. + /// + [JsonPropertyName("title")] + public required string Title { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellCreateRequest.cs b/src/OpenCode.Sdk/Models/ShellCreateRequest.cs new file mode 100644 index 00000000..3d9bdad9 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellCreateRequest.cs @@ -0,0 +1,45 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell create request value. +/// +public sealed record ShellCreateRequest +{ + /// + /// Gets the command value. + /// + [JsonPropertyName("command")] + public required string Command { get; init; } + + /// + /// Gets the cwd value. + /// + [JsonPropertyName("cwd")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] + public string? Cwd { get; init; } + + /// + /// Gets the timeout value. + /// + [JsonPropertyName("timeout")] + public required long Timeout { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public IReadOnlyDictionary Metadata { get; init => field = OptionalCollectionInput.Normalize(value, field, static input => new ReadOnlyDictionary(input.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal))); } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellInfo.cs b/src/OpenCode.Sdk/Models/ShellInfo.cs new file mode 100644 index 00000000..9898c848 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellInfo.cs @@ -0,0 +1,85 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell info value. +/// +public sealed record ShellInfo +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public required ShellInfoStatus Status { get; init; } + + /// + /// Gets the command value. + /// + [JsonPropertyName("command")] + public required string Command { get; init; } + + /// + /// Gets the cwd value. + /// + [JsonPropertyName("cwd")] + public required string Cwd { get; init; } + + /// + /// Gets the shell value. + /// + [JsonPropertyName("shell")] + public required string Shell { get; init; } + + /// + /// Gets the file value. + /// + [JsonPropertyName("file")] + public required string File { get; init; } + + /// + /// Gets the pid value. + /// + [JsonPropertyName("pid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public long? Pid { get; init; } + + /// + /// Gets the exit value. + /// + [JsonPropertyName("exit")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public double? Exit { get; init; } + + /// + /// Gets the metadata value. + /// + [JsonPropertyName("metadata")] + public required IReadOnlyDictionary Metadata + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)); + } + } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required ShellInfoTime Time { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellInfoStatus.cs b/src/OpenCode.Sdk/Models/ShellInfoStatus.cs new file mode 100644 index 00000000..cae3418c --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellInfoStatus.cs @@ -0,0 +1,33 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Defines the supported shell info status values. +/// +[JsonConverter(typeof(StrictJsonStringEnumConverter))] +public enum ShellInfoStatus +{ + /// + /// Represents the 'running' wire value. + /// + [JsonStringEnumMemberName("running")] + Running, + /// + /// Represents the 'exited' wire value. + /// + [JsonStringEnumMemberName("exited")] + Exited, + /// + /// Represents the 'timeout' wire value. + /// + [JsonStringEnumMemberName("timeout")] + Timeout, + /// + /// Represents the 'killed' wire value. + /// + [JsonStringEnumMemberName("killed")] + Killed +} diff --git a/src/OpenCode.Sdk/Models/ShellInfoTime.cs b/src/OpenCode.Sdk/Models/ShellInfoTime.cs new file mode 100644 index 00000000..98a41926 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellInfoTime.cs @@ -0,0 +1,25 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell info time value. +/// +public sealed record ShellInfoTime +{ + /// + /// Gets the started value. + /// + [JsonPropertyName("started")] + public required double Started { get; init; } + + /// + /// Gets the completed value. + /// + [JsonPropertyName("completed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public double? Completed { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellNotFoundError.cs b/src/OpenCode.Sdk/Models/ShellNotFoundError.cs new file mode 100644 index 00000000..58c2284b --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellNotFoundError.cs @@ -0,0 +1,28 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell not found error value. +/// +public sealed record ShellNotFoundError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "ShellNotFoundError"; + + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/ShellTimeoutRequest.cs b/src/OpenCode.Sdk/Models/ShellTimeoutRequest.cs new file mode 100644 index 00000000..d9e56909 --- /dev/null +++ b/src/OpenCode.Sdk/Models/ShellTimeoutRequest.cs @@ -0,0 +1,22 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a shell timeout request value. +/// +public sealed record ShellTimeoutRequest +{ + /// + /// Gets the timeout value. + /// + [JsonPropertyName("timeout")] + public required long Timeout { get; init; } + + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + [JsonIgnore] + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/OpenCodeClient.cs b/src/OpenCode.Sdk/OpenCodeClient.cs index db473711..cfba24d2 100644 --- a/src/OpenCode.Sdk/OpenCodeClient.cs +++ b/src/OpenCode.Sdk/OpenCodeClient.cs @@ -11,6 +11,7 @@ public class OpenCodeClient : IDisposable { private readonly Pipeline? _pipeline; private readonly SessionsClient? _sessions; + private readonly ShellsClient? _shells; /// /// Initializes a client that owns its connection to the endpoint. /// @@ -19,6 +20,7 @@ public OpenCodeClient(OpenCodeClientOptions options) { _pipeline = Pipeline.Create(options); _sessions = new SessionsClient(_pipeline); + _shells = new ShellsClient(_pipeline); } /// @@ -30,6 +32,7 @@ internal OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { _pipeline = Pipeline.Create(httpClient, options); _sessions = new SessionsClient(_pipeline); + _shells = new ShellsClient(_pipeline); } /// @@ -64,6 +67,10 @@ protected virtual void Dispose(bool disposing) /// Gets the 'Sessions' collection client. /// public virtual SessionsClient Sessions => _sessions ?? throw MockSeam.CreateError("OpenCodeClient", "Sessions"); + /// + /// Gets the 'Shells' collection client. + /// + public virtual ShellsClient Shells => _shells ?? throw MockSeam.CreateError("OpenCodeClient", "Shells"); private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("OpenCodeClient", "Pipeline"); /// diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index f75d6177..9416c6fe 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -1,6 +1,7 @@ // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -135,5 +136,182 @@ public static string ListSessions(SessionListRequest? request = null) query.AddText("cursor", request.Cursor); return path + query.Value; } + + /// + /// The 'DELETE /api/session/{sessionID}' route template. + /// + public const string RemoveSessionTemplate = "/api/session/{sessionID}"; + /// + /// Builds the '/api/session/{sessionID}' route. + /// + /// The 'sessionID' route value. + /// The escaped route. + public static string RemoveSession(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + return "/api/session/" + Uri.EscapeDataString(sessionId); + } + + /// + /// The 'POST /api/session/{sessionID}/rename' route template. + /// + public const string RenameSessionTemplate = "/api/session/{sessionID}/rename"; + /// + /// Builds the '/api/session/{sessionID}/rename' route. + /// + /// The 'sessionID' route value. + /// The escaped route. + public static string RenameSession(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + return "/api/session/" + Uri.EscapeDataString(sessionId) + "/rename"; + } + } + + /// + /// Defines the 'Shells' routes. + /// + public static class Shells + { + /// + /// The 'POST /api/shell' route template. + /// + public const string CreateShellTemplate = "/api/shell"; + /// + /// Builds the '/api/shell' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string CreateShell(ShellCreateRequest? request = null) + { + var path = "/api/shell"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'GET /api/shell/{id}' route template. + /// + public const string GetShellTemplate = "/api/shell/{id}"; + /// + /// Builds the '/api/shell/{id}' route. + /// + /// The 'id' route value. + /// The request shaping the query. + /// The escaped route. + public static string GetShell(string id, ShellRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + var path = "/api/shell/" + Uri.EscapeDataString(id); + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'GET /api/shell' route template. + /// + public const string ListShellsTemplate = "/api/shell"; + /// + /// Builds the '/api/shell' route. + /// + /// The request shaping the query. + /// The escaped route. + public static string ListShells(ShellListRequest? request = null) + { + var path = "/api/shell"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'DELETE /api/shell/{id}' route template. + /// + public const string RemoveShellTemplate = "/api/shell/{id}"; + /// + /// Builds the '/api/shell/{id}' route. + /// + /// The 'id' route value. + /// The request shaping the query. + /// The escaped route. + public static string RemoveShell(string id, ShellRemoveRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + var path = "/api/shell/" + Uri.EscapeDataString(id); + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } + + /// + /// The 'PATCH /api/shell/{id}/timeout' route template. + /// + public const string TimeoutShellTemplate = "/api/shell/{id}/timeout"; + /// + /// Builds the '/api/shell/{id}/timeout' route. + /// + /// The 'id' route value. + /// The request shaping the query. + /// The escaped route. + public static string TimeoutShell(string id, ShellTimeoutRequest? request = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + var path = "/api/shell/" + Uri.EscapeDataString(id) + "/timeout"; + if (request is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddLocation("location", request.Location); + return path + query.Value; + } } } diff --git a/src/OpenCode.Sdk/Sessions/SessionClient.cs b/src/OpenCode.Sdk/Sessions/SessionClient.cs index 4c3fbf8e..2d83fe95 100644 --- a/src/OpenCode.Sdk/Sessions/SessionClient.cs +++ b/src/OpenCode.Sdk/Sessions/SessionClient.cs @@ -2,6 +2,8 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -75,4 +77,32 @@ public virtual Task ListMessagesAsync(MessageListRequest? r { return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListMessages(SessionId, request), MessageListResponseAdapter.Instance, requestOptions, cancellationToken); } + + /// + /// Delete session + /// + /// The per-call options. + /// The cancellation token. + /// The 'SessionRemoveResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RemoveSessionAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Sessions.RemoveSession(SessionId), SessionRemoveResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Rename session + /// + /// The request body. + /// The per-call options. + /// The cancellation token. + /// The 'SessionRenameResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RenameSessionAsync(SessionRenameRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Sessions.RenameSession(SessionId), request, OpenCodeJsonContext.Default.SessionRenameRequest, SessionRenameResponseAdapter.Instance, requestOptions, cancellationToken); + } } diff --git a/src/OpenCode.Sdk/Sessions/SessionRemoveResponse.cs b/src/OpenCode.Sdk/Sessions/SessionRemoveResponse.cs new file mode 100644 index 00000000..dab0a9c6 --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionRemoveResponse.cs @@ -0,0 +1,30 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'DELETE /api/session/{sessionID}' operation. +/// +public sealed record SessionRemoveResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'SessionRemoveResponse' envelope. + /// + public SessionRemoveResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionRemoveResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} diff --git a/src/OpenCode.Sdk/Sessions/SessionRenameResponse.cs b/src/OpenCode.Sdk/Sessions/SessionRenameResponse.cs new file mode 100644 index 00000000..93733ecf --- /dev/null +++ b/src/OpenCode.Sdk/Sessions/SessionRenameResponse.cs @@ -0,0 +1,30 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'POST /api/session/{sessionID}/rename' operation. +/// +public sealed record SessionRenameResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'SessionRenameResponse' envelope. + /// + public SessionRenameResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionRenameResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellClient.cs b/src/OpenCode.Sdk/Shells/ShellClient.cs new file mode 100644 index 00000000..ce502ad3 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellClient.cs @@ -0,0 +1,81 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// A bound 'ShellClient' handle; it holds an immutable identifier and the shared pipeline. +/// +public class ShellClient +{ + private readonly Pipeline? _pipeline; + private readonly string? _id; + internal ShellClient(Pipeline pipeline, string id) + { + ArgumentNullException.ThrowIfNull(pipeline); + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + _pipeline = pipeline; + _id = id; + } + + /// + /// Initializes a mocking instance; members invoked without an override throw an instructive failure. + /// + protected ShellClient() + { + } + + private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("ShellClient", "Pipeline"); + private string Id => _id ?? throw MockSeam.CreateError("ShellClient", "Id"); + + /// + /// Get shell command + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'ShellResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task GetShellAsync(ShellRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Shells.GetShell(Id, request), ShellResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Remove shell command + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'ShellRemoveResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task RemoveShellAsync(ShellRemoveRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Delete, OpenCodeRoutes.Shells.RemoveShell(Id, request), ShellRemoveResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Update shell timeout + /// + /// The request body. + /// The per-call options. + /// The cancellation token. + /// The 'ShellTimeoutResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task TimeoutShellAsync(ShellTimeoutRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return Pipeline.ExecuteAsync(OpenCodeHttpMethod.Patch, OpenCodeRoutes.Shells.TimeoutShell(Id, request), request, OpenCodeJsonContext.Default.ShellTimeoutRequest, ShellTimeoutResponseAdapter.Instance, requestOptions, cancellationToken); + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellCreateResponse.cs b/src/OpenCode.Sdk/Shells/ShellCreateResponse.cs new file mode 100644 index 00000000..3f14957b --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellCreateResponse.cs @@ -0,0 +1,65 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'POST /api/shell' operation. +/// +public sealed record ShellCreateResponse : OpenCodeResponse +{ + private readonly ShellInfo? _shell; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellCreateResponse' envelope. + /// + public ShellCreateResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellCreateResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shell = null!; + Location = null!; + } + + /// + /// Gets the Shell payload; guarded on the error path. + /// + public required ShellInfo Shell { get => _shell ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shell."); init => _shell = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shell is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shell = ").Append(_shell); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellListRequest.cs b/src/OpenCode.Sdk/Shells/ShellListRequest.cs new file mode 100644 index 00000000..fcd39393 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellListRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'GET /api/shell' query. +/// +public sealed record ShellListRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Shells/ShellListResponse.cs b/src/OpenCode.Sdk/Shells/ShellListResponse.cs new file mode 100644 index 00000000..c5c46ee2 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellListResponse.cs @@ -0,0 +1,66 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/shell' operation. +/// +public sealed record ShellListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _shells; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellListResponse' envelope. + /// + public ShellListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shells = null!; + Location = null!; + } + + /// + /// Gets the Shells payload; guarded on the error path. + /// + public required IReadOnlyList Shells { get => _shells ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shells."); init => _shells = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shells is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shells = ").Append(_shells); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs b/src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs new file mode 100644 index 00000000..da41b54e --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellRemoveRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'DELETE /api/shell/{id}' query. +/// +public sealed record ShellRemoveRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Shells/ShellRemoveResponse.cs b/src/OpenCode.Sdk/Shells/ShellRemoveResponse.cs new file mode 100644 index 00000000..baa66a48 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellRemoveResponse.cs @@ -0,0 +1,30 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'DELETE /api/shell/{id}' operation. +/// +public sealed record ShellRemoveResponse : OpenCodeResponse +{ + /// + /// Initializes a success instance of the 'ShellRemoveResponse' envelope. + /// + public ShellRemoveResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellRemoveResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellRequest.cs b/src/OpenCode.Sdk/Shells/ShellRequest.cs new file mode 100644 index 00000000..b8d4c0be --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellRequest.cs @@ -0,0 +1,13 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +namespace OpenCode.Sdk; +/// +/// Shapes the 'GET /api/shell/{id}' query. +/// +public sealed record ShellRequest +{ + /// + /// Gets the 'location' query value; the server default applies when unset. + /// + public LocationSelector? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Shells/ShellResponse.cs b/src/OpenCode.Sdk/Shells/ShellResponse.cs new file mode 100644 index 00000000..9ff5c2dd --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellResponse.cs @@ -0,0 +1,65 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'GET /api/shell/{id}' operation. +/// +public sealed record ShellResponse : OpenCodeResponse +{ + private readonly ShellInfo? _shell; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellResponse' envelope. + /// + public ShellResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shell = null!; + Location = null!; + } + + /// + /// Gets the Shell payload; guarded on the error path. + /// + public required ShellInfo Shell { get => _shell ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shell."); init => _shell = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shell is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shell = ").Append(_shell); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellTimeoutResponse.cs b/src/OpenCode.Sdk/Shells/ShellTimeoutResponse.cs new file mode 100644 index 00000000..ae4e0ede --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellTimeoutResponse.cs @@ -0,0 +1,65 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using System.Diagnostics.CodeAnalysis; +using System.Text; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// Represents the response of the 'PATCH /api/shell/{id}/timeout' operation. +/// +public sealed record ShellTimeoutResponse : OpenCodeResponse +{ + private readonly ShellInfo? _shell; + private readonly LocationInfo? _location; + /// + /// Initializes a success instance of the 'ShellTimeoutResponse' envelope. + /// + public ShellTimeoutResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal ShellTimeoutResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Shell = null!; + Location = null!; + } + + /// + /// Gets the Shell payload; guarded on the error path. + /// + public required ShellInfo Shell { get => _shell ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Shell."); init => _shell = value; } + /// + /// Gets the location the server resolved for the request; guarded on the error path. + /// + public required LocationInfo Location { get => _location ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Location."); init => _location = value; } + + /// + /// Prints the shared metadata and appends the payload only when it is present. + /// + protected override bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + var printed = base.PrintMembers(builder); + if (_shell is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Shell = ").Append(_shell); + return true; + } +} diff --git a/src/OpenCode.Sdk/Shells/ShellsClient.cs b/src/OpenCode.Sdk/Shells/ShellsClient.cs new file mode 100644 index 00000000..638a0df6 --- /dev/null +++ b/src/OpenCode.Sdk/Shells/ShellsClient.cs @@ -0,0 +1,74 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand — change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk; +/// +/// The 'ShellsClient' collection client. +/// +public class ShellsClient +{ + private readonly Pipeline? _pipeline; + internal ShellsClient(Pipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + _pipeline = pipeline; + } + + /// + /// Initializes a mocking instance; members invoked without an override throw an instructive failure. + /// + protected ShellsClient() + { + } + + /// + /// Gets a bound 'ShellClient'; the handle never caches server state. + /// + /// The 'id' route value. + /// The bound 'ShellClient'. + public virtual ShellClient GetShellClient(string id) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + if (id is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(id)); + } + + return new ShellClient(Pipeline, id); + } + + private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("ShellsClient", "Pipeline"); + + /// + /// Run shell command + /// + /// The request body. + /// The per-call options. + /// The cancellation token. + /// The 'ShellCreateResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task CreateShellAsync(ShellCreateRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Shells.CreateShell(request), request, OpenCodeJsonContext.Default.ShellCreateRequest, ShellCreateResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List running shell commands + /// + /// The request shaping the query. + /// The per-call options. + /// The cancellation token. + /// The 'ShellListResponse' envelope. + /// The API returned an error status (declared: 400, 401) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListShellsAsync(ShellListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Shells.ListShells(request), ShellListResponseAdapter.Instance, requestOptions, cancellationToken); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-shell.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-shell.json new file mode 100644 index 00000000..944cd7ad --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-shell.json @@ -0,0 +1,13 @@ +{ + "id": "sh_100", + "status": "running", + "command": "ls -la", + "cwd": "/repo", + "shell": "/bin/bash", + "file": "/tmp/sh_100.log", + "pid": 4242, + "metadata": {}, + "time": { + "started": 1755200000 + } +} diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index 6b8c0439..3b431353 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -137,6 +137,38 @@ public async Task ListMessagesAsync_Should_Return_The_500_Error_On_The_NoThrow_S await Assert.That(response.RawBody).Contains("boom"); } + [Test] + public async Task RemoveSessionAsync_Should_Treat_The_204_As_A_Bodiless_Success() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Sessions.GetSessionClient("ses_100").RemoveSessionAsync(); + + await Assert.That(response.Status).IsEqualTo(204); + await Assert.That(response.IsError).IsFalse(); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100")); + } + + [Test] + public async Task RenameSessionAsync_Should_Send_The_Typed_Body() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Sessions.GetSessionClient("ses_100").RenameSessionAsync(new SessionRenameRequest + { + Title = "Renamed session", + }); + + await Assert.That(response.Status).IsEqualTo(204); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(request.RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/session/ses_100/rename"); + await Assert.That(request.Body).IsEqualTo("{\"title\":\"Renamed session\"}"); + } + [Test] public async Task ListMessagesAsync_Should_Throw_The_Declared_404_Error() { diff --git a/tests/OpenCode.Sdk.Tests/Shells/ShellClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Shells/ShellClientContractTests.cs new file mode 100644 index 00000000..e176ca06 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Shells/ShellClientContractTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +public sealed class ShellClientContractTests +{ + [Test] + public async Task GetShellAsync_Should_Return_The_Typed_Shell_With_Its_Location() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope(payload)); + + var response = await scenario.Client.Shells.GetShellClient("sh_100").GetShellAsync(); + + await Assert.That(response.Shell.Id).IsEqualTo("sh_100"); + await Assert.That(response.Shell.Time.Started).IsEqualTo(1755200000); + await Assert.That(response.Location.Project.Id).IsEqualTo("prj_1"); + await Assert.That(scenario.Requests.Single().RequestUri) + .IsEqualTo(new Uri("http://localhost:4096/api/shell/sh_100")); + } + + [Test] + public async Task RemoveShellAsync_Should_Treat_The_204_As_A_Bodiless_Success() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, string.Empty); + + var response = await scenario.Client.Shells.GetShellClient("sh_100").RemoveShellAsync(); + + await Assert.That(response.Status).IsEqualTo(204); + await Assert.That(response.IsError).IsFalse(); + await Assert.That(response.RawBody).IsNull(); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Delete); + await Assert.That(request.RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/shell/sh_100")); + } + + [Test] + public async Task RemoveShellAsync_Should_Treat_A_204_With_A_Body_As_A_Protocol_Failure() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NoContent, "{}"); + + _ = await Assert + .That(async () => _ = await scenario.Client.Shells.GetShellClient("sh_100").RemoveShellAsync()) + .Throws(); + } + + [Test] + public async Task RemoveShellAsync_Should_Treat_A_200_As_An_Undeclared_Success() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, "{}"); + + _ = await Assert + .That(async () => _ = await scenario.Client.Shells.GetShellClient("sh_100").RemoveShellAsync()) + .Throws(); + } + + [Test] + public async Task TimeoutShellAsync_Should_Send_The_Patch_Body_Beside_The_Location_Query() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope(payload)); + + _ = await scenario.Client.Shells.GetShellClient("sh_100").TimeoutShellAsync(new ShellTimeoutRequest + { + Timeout = 9000, + Location = new LocationSelector { Workspace = "wrk_1" }, + }); + + var request = scenario.Requests.Single(); + await Assert.That(request.Method.Method).IsEqualTo("PATCH"); + await Assert.That(request.RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/shell/sh_100/timeout?location[workspace]=wrk_1"); + await Assert.That(request.Body).IsEqualTo("{\"timeout\":9000}"); + } + + [Test] + public async Task GetShellAsync_Should_Throw_The_Declared_404_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.ShellNotFoundError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Shells.GetShellClient("sh_9").GetShellAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(404); + await Assert.That(exception.Error).IsTypeOf(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Shells/ShellsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Shells/ShellsClientContractTests.cs new file mode 100644 index 00000000..4a13f060 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Shells/ShellsClientContractTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; +using OpenCode.Sdk.TestSupport; + +namespace OpenCode.Sdk.Tests; + +public sealed class ShellsClientContractTests +{ + [Test] + public async Task ListShellsAsync_Should_Return_The_Typed_Page_With_Its_Location() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope($"[{payload}]")); + + var response = await scenario.Client.Shells.ListShellsAsync(); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.Shells.Single().Id).IsEqualTo("sh_100"); + await Assert.That(response.Shells.Single().Status).IsEqualTo(ShellInfoStatus.Running); + await Assert.That(response.Location.Directory).IsEqualTo("/repo"); + await Assert.That(scenario.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/shell")); + } + + [Test] + public async Task ListShellsAsync_Should_Compose_The_Deep_Object_Location_Query() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope($"[{payload}]")); + + _ = await scenario.Client.Shells.ListShellsAsync(new ShellListRequest + { + Location = new LocationSelector { Directory = "/a b", Workspace = "wrk_1" }, + }); + + await Assert.That(scenario.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/shell?location[directory]=%2Fa%20b&location[workspace]=wrk_1"); + } + + [Test] + public async Task CreateShellAsync_Should_Send_The_Body_Beside_The_Location_Query() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.LocationEnvelope(payload)); + + var response = await scenario.Client.Shells.CreateShellAsync(new ShellCreateRequest + { + Command = "ls -la", + Timeout = 5000, + Location = new LocationSelector { Directory = "/repo" }, + }); + + await Assert.That(response.Shell.Id).IsEqualTo("sh_100"); + var request = scenario.Requests.Single(); + await Assert.That(request.Method).IsEqualTo(HttpMethod.Post); + await Assert.That(request.RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/shell?location[directory]=%2Frepo"); + await Assert.That(request.Body).IsEqualTo("{\"command\":\"ls -la\",\"timeout\":5000,\"metadata\":{}}"); + } + + [Test] + public async Task CreateShellAsync_Should_Throw_The_Declared_400_Error() + { + using var scenario = ContractScenario.Responding(HttpStatusCode.BadRequest, WireBodyData.InvalidRequestError); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Shells.CreateShellAsync(new ShellCreateRequest + { + Command = "ls", + Timeout = 1, + })) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + [Test] + public async Task ListShellsAsync_Should_Treat_A_Missing_Location_Sibling_As_A_Protocol_Failure() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-shell.json"); + using var scenario = ContractScenario.Responding(HttpStatusCode.OK, $"{{\"data\":[{payload}]}}"); + + _ = await Assert + .That(async () => _ = await scenario.Client.Shells.ListShellsAsync()) + .Throws(); + } + + [Test] + [Arguments(" ")] + [Arguments(".")] + [Arguments("..")] + public async Task GetShellClient_Should_Refuse_An_Id_The_Route_Would_Refuse(string id) + { + using var scenario = ContractScenario.Responding(); + + var exception = Assert.Throws(() => _ = scenario.Client.Shells.GetShellClient(id)); + + await Assert.That(exception.ParamName).IsEqualTo("id"); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index dedc855d..56c788f6 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -70,6 +70,7 @@ protected OpenCodeClient() { } public OpenCodeClient(OpenCode.Sdk.OpenCodeClientOptions options) { } public virtual OpenCode.Sdk.SessionsClient Sessions { get; } + public virtual OpenCode.Sdk.ShellsClient Shells { get; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } public virtual System.Threading.Tasks.Task GetHealthAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -115,10 +116,27 @@ public const string GetSessionTemplate = "/api/session/{sessionID}"; public const string ListMessagesTemplate = "/api/session/{sessionID}/message"; public const string ListSessionsTemplate = "/api/session"; + public const string RemoveSessionTemplate = "/api/session/{sessionID}"; + public const string RenameSessionTemplate = "/api/session/{sessionID}/rename"; public static string GetMessage(string sessionId, string messageId) { } public static string GetSession(string sessionId) { } public static string ListMessages(string sessionId, OpenCode.Sdk.MessageListRequest? request = null) { } public static string ListSessions(OpenCode.Sdk.SessionListRequest? request = null) { } + public static string RemoveSession(string sessionId) { } + public static string RenameSession(string sessionId) { } + } + public static class Shells + { + public const string CreateShellTemplate = "/api/shell"; + public const string GetShellTemplate = "/api/shell/{id}"; + public const string ListShellsTemplate = "/api/shell"; + public const string RemoveShellTemplate = "/api/shell/{id}"; + public const string TimeoutShellTemplate = "/api/shell/{id}/timeout"; + public static string CreateShell(OpenCode.Sdk.Models.ShellCreateRequest? request = null) { } + public static string GetShell(string id, OpenCode.Sdk.ShellRequest? request = null) { } + public static string ListShells(OpenCode.Sdk.ShellListRequest? request = null) { } + public static string RemoveShell(string id, OpenCode.Sdk.ShellRemoveRequest? request = null) { } + public static string TimeoutShell(string id, OpenCode.Sdk.Models.ShellTimeoutRequest? request = null) { } } } public class OpenCodeTransportException : OpenCode.Sdk.OpenCodeException @@ -133,6 +151,8 @@ public virtual System.Threading.Tasks.Task GetMessageAsync(string messageId, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task GetSessionAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task ListMessagesAsync(OpenCode.Sdk.MessageListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RemoveSessionAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RenameSessionAsync(OpenCode.Sdk.Models.SessionRenameRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } public sealed class SessionCreateResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { @@ -171,6 +191,14 @@ public static OpenCode.Sdk.SessionParentFilter RootOnly { get; } public static OpenCode.Sdk.SessionParentFilter Of(string sessionId) { } } + public sealed class SessionRemoveResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionRemoveResponse() { } + } + public sealed class SessionRenameResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionRenameResponse() { } + } public sealed class SessionResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { public SessionResponse() { } @@ -184,6 +212,67 @@ public virtual OpenCode.Sdk.SessionClient GetSessionClient(string sessionId) { } public virtual System.Threading.Tasks.Task ListSessionsAsync(OpenCode.Sdk.SessionListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } + public class ShellClient + { + protected ShellClient() { } + public virtual System.Threading.Tasks.Task GetShellAsync(OpenCode.Sdk.ShellRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task RemoveShellAsync(OpenCode.Sdk.ShellRemoveRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task TimeoutShellAsync(OpenCode.Sdk.Models.ShellTimeoutRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + } + public sealed class ShellCreateResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellCreateResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required OpenCode.Sdk.Models.ShellInfo Shell { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class ShellListRequest : System.IEquatable + { + public ShellListRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } + public sealed class ShellListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellListResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required System.Collections.Generic.IReadOnlyList Shells { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class ShellRemoveRequest : System.IEquatable + { + public ShellRemoveRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } + public sealed class ShellRemoveResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellRemoveResponse() { } + } + public sealed class ShellRequest : System.IEquatable + { + public ShellRequest() { } + public OpenCode.Sdk.LocationSelector? Location { get; init; } + } + public sealed class ShellResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required OpenCode.Sdk.Models.ShellInfo Shell { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class ShellTimeoutResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public ShellTimeoutResponse() { } + public required OpenCode.Sdk.Models.LocationInfo Location { get; init; } + public required OpenCode.Sdk.Models.ShellInfo Shell { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public class ShellsClient + { + protected ShellsClient() { } + public virtual System.Threading.Tasks.Task CreateShellAsync(OpenCode.Sdk.Models.ShellCreateRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual OpenCode.Sdk.ShellClient GetShellClient(string id) { } + public virtual System.Threading.Tasks.Task ListShellsAsync(OpenCode.Sdk.ShellListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + } } namespace OpenCode.Sdk.Models { @@ -233,6 +322,28 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class LocationInfo : System.IEquatable + { + public LocationInfo() { } + [System.Text.Json.Serialization.JsonPropertyName("directory")] + public required string Directory { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("project")] + public required OpenCode.Sdk.Models.LocationInfoProject Project { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("workspaceID")] + public string? WorkspaceId { get; init; } + } + public sealed class LocationInfoProject : System.IEquatable + { + public LocationInfoProject() { } + [System.Text.Json.Serialization.JsonPropertyName("canonical")] + public required string Canonical { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("directory")] + public required string Directory { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + } public sealed class LocationRef : System.IEquatable { public LocationRef() { } @@ -1063,6 +1174,12 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class SessionRenameRequest : System.IEquatable + { + public SessionRenameRequest() { } + [System.Text.Json.Serialization.JsonPropertyName("title")] + public required string Title { get; init; } + } public sealed class SessionRevert : System.IEquatable { public SessionRevert() { } @@ -1091,6 +1208,90 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public required string Type { get; init; } } + public sealed class ShellCreateRequest : System.IEquatable + { + public ShellCreateRequest() { } + [System.Text.Json.Serialization.JsonPropertyName("command")] + public required string Command { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("cwd")] + public string? Cwd { get; init; } + [System.Text.Json.Serialization.JsonIgnore] + public OpenCode.Sdk.LocationSelector? Location { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("metadata")] + public System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("timeout")] + public required long Timeout { get; init; } + } + public sealed class ShellInfo : System.IEquatable + { + public ShellInfo() { } + [System.Text.Json.Serialization.JsonPropertyName("command")] + public required string Command { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("cwd")] + public required string Cwd { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingValueJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("exit")] + public double? Exit { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("file")] + public required string File { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("metadata")] + public required System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingValueJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("pid")] + public long? Pid { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("shell")] + public required string Shell { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("status")] + public required OpenCode.Sdk.Models.ShellInfoStatus Status { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("time")] + public required OpenCode.Sdk.Models.ShellInfoTime Time { get; init; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] + public enum ShellInfoStatus + { + [System.Text.Json.Serialization.JsonStringEnumMemberName("running")] + Running = 0, + [System.Text.Json.Serialization.JsonStringEnumMemberName("exited")] + Exited = 1, + [System.Text.Json.Serialization.JsonStringEnumMemberName("timeout")] + Timeout = 2, + [System.Text.Json.Serialization.JsonStringEnumMemberName("killed")] + Killed = 3, + } + public sealed class ShellInfoTime : System.IEquatable + { + public ShellInfoTime() { } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.WireNullRejectingValueJsonConverter))] + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("completed")] + public double? Completed { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("started")] + public required double Started { get; init; } + } + public sealed class ShellNotFoundError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable + { + public ShellNotFoundError() { } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("message")] + public required string Message { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("_tag")] + public override string Tag { get; } + } + public sealed class ShellTimeoutRequest : System.IEquatable + { + public ShellTimeoutRequest() { } + [System.Text.Json.Serialization.JsonIgnore] + public OpenCode.Sdk.LocationSelector? Location { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("timeout")] + public required long Timeout { get; init; } + } public sealed class TokenUsageInfo : System.IEquatable { public TokenUsageInfo() { } diff --git a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs index 591828e7..6f7b6cb4 100644 --- a/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs +++ b/tests/OpenCode.Sdk.Tests/Support/WireBodyData.cs @@ -17,6 +17,11 @@ internal static class WireBodyData public const string InvalidRequestError = "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}"; + public const string ShellNotFoundError = "{\"_tag\":\"ShellNotFoundError\",\"id\":\"sh_9\",\"message\":\"gone\"}"; + + public const string ResolvedLocation = + "{\"directory\":\"/repo\",\"project\":{\"id\":\"prj_1\",\"directory\":\"/repo\",\"canonical\":\"/repo\"}}"; + public static string Envelope(string datum) { ArgumentNullException.ThrowIfNull(datum); @@ -24,6 +29,14 @@ public static string Envelope(string datum) return $"{{\"data\":{datum}}}"; } + public static string LocationEnvelope(string datum, string location = ResolvedLocation) + { + ArgumentNullException.ThrowIfNull(datum); + ArgumentNullException.ThrowIfNull(location); + + return $"{{\"location\":{location},\"data\":{datum}}}"; + } + public static string Page(string items, string? previous = null, string? next = null) { ArgumentNullException.ThrowIfNull(items); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs index 84c1d20b..e41e1c57 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs @@ -13,6 +13,11 @@ public sealed class OperationNamePolicyTests [Arguments("v2.session.list", "get", "ListSessionsAsync")] [Arguments("v2.session.create", "post", "CreateSessionAsync")] [Arguments("v2.message.list", "get", "ListMessagesAsync")] + [Arguments("v2.session.remove", "delete", "RemoveSessionAsync")] + [Arguments("v2.session.rename", "post", "RenameSessionAsync")] + [Arguments("v2.shell.list", "get", "ListShellsAsync")] + [Arguments("v2.shell.timeout", "patch", "TimeoutShellAsync")] + [Arguments("v2.shell.output", "get", "GetOutputAsync")] public async Task MethodName_Should_Lead_With_The_Structural_Verb(string operationId, string method, string expected) { await Assert.That(OperationNamePolicy.MethodName(Operation(operationId, method))).IsEqualTo(expected); @@ -38,6 +43,10 @@ public async Task MethodName_Should_Return_Null_When_The_Group_Cannot_Be_Plurali [Arguments("v2.session.list", "get", "SessionListResponse")] [Arguments("v2.session.create", "post", "SessionCreateResponse")] [Arguments("v2.message.list", "get", "MessageListResponse")] + [Arguments("v2.session.remove", "delete", "SessionRemoveResponse")] + [Arguments("v2.shell.timeout", "patch", "ShellTimeoutResponse")] + [Arguments("v2.shell.output", "get", "ShellOutputResponse")] + [Arguments("v2.shell.get", "get", "ShellResponse")] public async Task ResponseTypeName_Should_Fold_Non_Get_Verbs(string operationId, string method, string expected) { await Assert.That(OperationNamePolicy.ResponseTypeName(Operation(operationId, method))).IsEqualTo(expected); @@ -81,6 +90,10 @@ public async Task PayloadName_Should_Return_Null_When_The_Group_Cannot_Be_Plural [Arguments("v2.session.list", "get", "SessionListRequest")] [Arguments("v2.message.list", "get", "MessageListRequest")] [Arguments("v2.session.create", "post", "SessionCreateRequest")] + [Arguments("v2.session.rename", "post", "SessionRenameRequest")] + [Arguments("v2.shell.timeout", "patch", "ShellTimeoutRequest")] + [Arguments("v2.shell.output", "get", "ShellOutputRequest")] + [Arguments("v2.shell.get", "get", "ShellRequest")] public async Task RequestTypeName_Should_Compose_Group_Subject_And_Verb(string operationId, string method, string expected) { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index ebd5ff68..c1ce574b 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -13,13 +13,17 @@ public async Task Bind_Should_Create_The_Selected_Pinned_Client_Plans() var plan = await new BindingTestHost().BindPinnedAsync(); await Assert.That(plan.Clients.Select(static client => client.Name) - .SequenceEqual(["OpenCodeClient", "SessionClient", "SessionsClient"], StringComparer.Ordinal)).IsTrue(); + .SequenceEqual( + ["OpenCodeClient", "SessionClient", "SessionsClient", "ShellClient", "ShellsClient"], + StringComparer.Ordinal)).IsTrue(); await Assert.That(plan.Clients.All(static client => client.Namespace == "OpenCode.Sdk")).IsTrue(); var root = plan.Clients.Single(static client => client.Role == ClientRole.Root); await Assert.That(root.Name).IsEqualTo("OpenCodeClient"); - await Assert.That(root.SubClients.Single().PropertyName).IsEqualTo("Sessions"); - await Assert.That(root.SubClients.Single().TypeName).IsEqualTo("SessionsClient"); + await Assert.That(root.SubClients.Select(static subClient => subClient.PropertyName) + .SequenceEqual(["Sessions", "Shells"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(root.SubClients.Select(static subClient => subClient.TypeName) + .SequenceEqual(["SessionsClient", "ShellsClient"], StringComparer.Ordinal)).IsTrue(); await Assert.That(root.HandleFactory).IsNull(); await Assert.That(root.HandleParameter).IsNull(); @@ -45,8 +49,7 @@ await Assert.That(health.ErrorMap.Statuses.Select(static status => status.Status await Assert.That(root.ContainerName).IsNull(); - var sessions = plan.Clients.Single(static client => client.Role == ClientRole.Collection); - await Assert.That(sessions.Name).IsEqualTo("SessionsClient"); + var sessions = plan.Clients.Single(static client => client.Name == "SessionsClient"); await Assert.That(sessions.ContainerName).IsEqualTo("Sessions"); await Assert.That(sessions.Operations.Select(static operation => operation.MethodName) .SequenceEqual(["CreateSessionAsync", "ListSessionsAsync"], StringComparer.Ordinal)).IsTrue(); @@ -77,18 +80,66 @@ await Assert.That(list.ErrorMap.Statuses[0].Tags.Select(static tag => tag.TypeNa await Assert.That(create.Envelope.PayloadName).IsEqualTo("Session"); } + [Test] + public async Task Bind_Should_Create_The_Selected_Pinned_Shell_Plans() + { + var plan = await new BindingTestHost().BindPinnedAsync(); + + var shells = plan.Clients.Single(static client => client.Name == "ShellsClient"); + await Assert.That(shells.ContainerName).IsEqualTo("Shells"); + await Assert.That(shells.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["CreateShellAsync", "ListShellsAsync"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(shells.HandleFactory!.MethodName).IsEqualTo("GetShellClient"); + await Assert.That(shells.HandleFactory.Parameter.WireName).IsEqualTo("id"); + + var createShell = shells.Operations.Single(static operation => operation.MethodName == "CreateShellAsync"); + await Assert.That(createShell.RequestBody!.TypeName).IsEqualTo("ShellCreateRequest"); + await Assert.That(createShell.QueryRequest!.RidesRequestBody).IsTrue(); + await Assert.That(createShell.QueryRequest.TypeName).IsEqualTo("ShellCreateRequest"); + await Assert.That(createShell.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocation); + await Assert.That(createShell.Envelope.PayloadTypeName).IsEqualTo("ShellInfo"); + await Assert.That(createShell.Envelope.LocationTypeName).IsEqualTo("LocationInfo"); + + var listShells = shells.Operations.Single(static operation => operation.MethodName == "ListShellsAsync"); + await Assert.That(listShells.QueryRequest!.TypeName).IsEqualTo("ShellListRequest"); + await Assert.That(listShells.QueryRequest.RidesRequestBody).IsFalse(); + await Assert.That(listShells.QueryRequest.Properties.Single().Kind).IsEqualTo(QueryValueKind.Location); + await Assert.That(listShells.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocationList); + } + [Test] public async Task Bind_Should_Create_The_Selected_Pinned_Handle_Plans() { var plan = await new BindingTestHost().BindPinnedAsync(); - var session = plan.Clients.Single(static client => client.Role == ClientRole.Handle); - await Assert.That(session.Name).IsEqualTo("SessionClient"); + var session = plan.Clients.Single(static client => client.Name == "SessionClient"); await Assert.That(session.ContainerName).IsEqualTo("Sessions"); await Assert.That(session.HandleParameter!.WireName).IsEqualTo("sessionID"); await Assert.That(session.HandleParameter.IsHandleParameter).IsTrue(); await Assert.That(session.Operations.Select(static operation => operation.MethodName) - .SequenceEqual(["GetMessageAsync", "GetSessionAsync", "ListMessagesAsync"], StringComparer.Ordinal)).IsTrue(); + .SequenceEqual( + ["GetMessageAsync", "GetSessionAsync", "ListMessagesAsync", "RemoveSessionAsync", "RenameSessionAsync"], + StringComparer.Ordinal)).IsTrue(); + + var remove = session.Operations.Single(static operation => operation.MethodName == "RemoveSessionAsync"); + await Assert.That(remove.HttpMethod).IsEqualTo("delete"); + await Assert.That(remove.Envelope.Kind).IsEqualTo(EnvelopeKind.NoContent); + await Assert.That(remove.Envelope.SuccessStatusCode).IsEqualTo(204); + await Assert.That(remove.Envelope.ResponseTypeName).IsEqualTo("SessionRemoveResponse"); + + var shell = plan.Clients.Single(static client => client.Name == "ShellClient"); + await Assert.That(shell.HandleParameter!.WireName).IsEqualTo("id"); + await Assert.That(shell.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["GetShellAsync", "RemoveShellAsync", "TimeoutShellAsync"], StringComparer.Ordinal)).IsTrue(); + + var timeout = shell.Operations.Single(static operation => operation.MethodName == "TimeoutShellAsync"); + await Assert.That(timeout.HttpMethod).IsEqualTo("patch"); + await Assert.That(timeout.RequestBody!.TypeName).IsEqualTo("ShellTimeoutRequest"); + await Assert.That(timeout.QueryRequest!.RidesRequestBody).IsTrue(); + + var getShell = shell.Operations.Single(static operation => operation.MethodName == "GetShellAsync"); + await Assert.That(getShell.QueryRequest!.TypeName).IsEqualTo("ShellRequest"); + await Assert.That(getShell.Envelope.Kind).IsEqualTo(EnvelopeKind.DataLocation); var messages = session.Operations.Single(static operation => operation.MethodName == "ListMessagesAsync"); await Assert.That(messages.QueryRequest!.TypeName).IsEqualTo("MessageListRequest"); @@ -828,6 +879,50 @@ public async Task Bind_Should_Refuse_A_Required_Location_Selector() await AssertWidgetRefusalAsync(document, "location selector"); } + [Test] + public async Task Bind_Should_Bind_A_Delete_Operation_With_A_No_Content_Success() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.widget.remove", method: "delete", path: "/api/widget/{id}", configure: operation => + { + _ = operation.Parameter("id", "path", schema => schema.Type("string"), required: true) + .WithoutResponse(200) + .Response(204); + }))); + + var plan = BindWidgets(document, "v2.widget.remove"); + + var remove = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(remove.MethodName).IsEqualTo("RemoveWidgetAsync"); + await Assert.That(remove.HttpMethod).IsEqualTo("delete"); + await Assert.That(remove.Envelope.Kind).IsEqualTo(EnvelopeKind.NoContent); + } + + [Test] + public async Task Bind_Should_Refuse_A_Request_Body_On_A_Delete_Operation() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.widget.remove", method: "delete", path: "/api/widget", configure: operation => operation + .RequestBody("application/json", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithoutResponse(200) + .Response(204)))); + + await AssertWidgetRefusalAsync(document, "must not carry a request body", "v2.widget.remove"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Patch_Operation_Without_A_Request_Body() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.timeout", method: "patch", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + await AssertWidgetRefusalAsync(document, "must carry a request body", "v2.widget.timeout"); + } + [Test] public async Task Bind_Should_Refuse_A_Request_Body_On_A_Get_Operation() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index 9037e22d..ea5db2c3 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -16,6 +16,7 @@ public sealed class SpecBinderTests "InvalidRequestError", "MessageNotFoundError", "SessionNotFoundError", + "ShellNotFoundError", "UnauthorizedError", "UnknownError", ]; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index cf0ca336..1599c54e 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -260,7 +260,7 @@ private static void ValidateSchemaAliases(SpecDocument document, ReachableSchema errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schema is not referenced by the selected profile"); } - if (!SchemaNodeComparer.DeepEquals(source, target)) + if (!SchemaNodeComparer.DeepEquals(source, target, document.Schemas)) { errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schemas must be structurally identical"); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index a7312a27..3328f4ef 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -13,7 +13,7 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; internal static class OperationNamePolicy { /// Identifier segments recognized as operation verbs when they close the identifier. - private static readonly string[] KnownVerbSegments = ["create", "get", "list"]; + private static readonly string[] KnownVerbSegments = ["create", "get", "list", "remove", "rename", "timeout"]; /// Gets the operation verb: a recognized final identifier segment, or the HTTP method. public static string Verb(SpecOperation operation) @@ -67,11 +67,14 @@ public static string ResponseTypeName(SpecOperation operation) return subject.Length is 0 ? GroupFallback(operation) : subject; } + /// Folds the Get verb exactly like the response name, so the pair stays uniform. public static string RequestTypeName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{Verb(operation)}Request"; + var verb = Verb(operation); + var verbSuffix = string.Equals(verb, "Get", StringComparison.Ordinal) ? string.Empty : verb; + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{verbSuffix}Request"; } /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 5d814840..85bb6a19 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -357,7 +357,9 @@ private bool CheckWireShape() var before = _errors.Count; var isGet = string.Equals(_operation.Method, "get", StringComparison.Ordinal); var isPost = string.Equals(_operation.Method, "post", StringComparison.Ordinal); - if (!isGet && !isPost) + var isDelete = string.Equals(_operation.Method, "delete", StringComparison.Ordinal); + var isPatch = string.Equals(_operation.Method, "patch", StringComparison.Ordinal); + if (!isGet && !isPost && !isDelete && !isPatch) { Refuse($"HTTP method '{_operation.Method}' is not supported"); } @@ -377,14 +379,14 @@ private bool CheckWireShape() Refuse("event-stream responses are not supported in M1"); } - if (isGet && _operation.RequestBody is not null) + if ((isGet || isDelete) && _operation.RequestBody is not null) { - Refuse("GET operations must not carry a request body"); + Refuse($"{_operation.Method.ToUpperInvariant()} operations must not carry a request body"); } - if (isPost && _operation.RequestBody is null) + if ((isPost || isPatch) && _operation.RequestBody is null) { - Refuse("POST operations must carry a request body"); + Refuse($"{_operation.Method.ToUpperInvariant()} operations must carry a request body"); } CheckParameterShapes(); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs index d9de7bcc..f429c339 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs @@ -4,69 +4,109 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// /// Structural equality over projected schema trees; node kinds without an explicit rule -/// compare unequal, so aliasing them refuses instead of guessing. +/// compare unequal, so aliasing them refuses instead of guessing. Promoted inline schemas +/// carry no nominal identity, so their references compare by resolved structure while +/// nominal component references keep target identity. /// internal static class SchemaNodeComparer { - public static bool DeepEquals(SchemaNode left, SchemaNode right) + public static bool DeepEquals(SchemaNode left, SchemaNode right, IReadOnlyDictionary graph) { ArgumentNullException.ThrowIfNull(left); ArgumentNullException.ThrowIfNull(right); + ArgumentNullException.ThrowIfNull(graph); - // Format is type-affecting ('format: uri' emits Uri, not string), so it joins - // identity ahead of the kind switch and rides every recursive comparison. - if (!string.Equals(left.Format, right.Format, StringComparison.Ordinal)) + return new GraphComparison(graph).NodesEqual(left, right); + } + + private sealed class GraphComparison(IReadOnlyDictionary graph) + { + private readonly IReadOnlyDictionary _graph = + graph ?? throw new ArgumentNullException(nameof(graph)); + + private readonly HashSet _visitedPairs = new(StringComparer.Ordinal); + + public bool NodesEqual(SchemaNode left, SchemaNode right) { - return false; + // Format is type-affecting ('format: uri' emits Uri, not string), so it joins + // identity ahead of the kind switch and rides every recursive comparison. + if (!string.Equals(left.Format, right.Format, StringComparison.Ordinal)) + { + return false; + } + + return (left, right) switch + { + (PrimitiveNode a, PrimitiveNode b) => a.Kind == b.Kind, + (LiteralNode a, LiteralNode b) => a.Kind == b.Kind && a.Dialect == b.Dialect + && string.Equals(a.Value, b.Value, StringComparison.Ordinal), + (EnumNode a, EnumNode b) => a.Values.SequenceEqual(b.Values, StringComparer.Ordinal), + (RefNode a, RefNode b) => RefsEqual(a, b), + (NullableNode a, NullableNode b) => NodesEqual(a.Inner, b.Inner), + (ArrayNode a, ArrayNode b) => NodesEqual(a.Item, b.Item), + (TupleNode a, TupleNode b) => SequencesEqual(a.Items, b.Items), + (DictionaryNode a, DictionaryNode b) => NodesEqual(a.Value, b.Value), + (UnionNode a, UnionNode b) => a.Keyword == b.Keyword && a.Classification == b.Classification + && SequencesEqual(a.Branches, b.Branches), + (ObjectNode a, ObjectNode b) => ObjectsEqual(a, b), + (FreeFormObjectNode, FreeFormObjectNode) => true, + (UnrestrictedNode, UnrestrictedNode) => true, + _ => false, + }; } - return (left, right) switch + private bool RefsEqual(RefNode left, RefNode right) { - (PrimitiveNode a, PrimitiveNode b) => a.Kind == b.Kind, - (LiteralNode a, LiteralNode b) => a.Kind == b.Kind && a.Dialect == b.Dialect - && string.Equals(a.Value, b.Value, StringComparison.Ordinal), - (EnumNode a, EnumNode b) => a.Values.SequenceEqual(b.Values, StringComparer.Ordinal), - (RefNode a, RefNode b) => string.Equals(a.Target, b.Target, StringComparison.Ordinal), - (NullableNode a, NullableNode b) => DeepEquals(a.Inner, b.Inner), - (ArrayNode a, ArrayNode b) => DeepEquals(a.Item, b.Item), - (TupleNode a, TupleNode b) => SequenceEquals(a.Items, b.Items), - (DictionaryNode a, DictionaryNode b) => DeepEquals(a.Value, b.Value), - (UnionNode a, UnionNode b) => a.Keyword == b.Keyword && a.Classification == b.Classification - && SequenceEquals(a.Branches, b.Branches), - (ObjectNode a, ObjectNode b) => ObjectsEqual(a, b), - (FreeFormObjectNode, FreeFormObjectNode) => true, - (UnrestrictedNode, UnrestrictedNode) => true, - _ => false, - }; - } + if (string.Equals(left.Target, right.Target, StringComparison.Ordinal)) + { + return true; + } - private static bool ObjectsEqual(ObjectNode left, ObjectNode right) => - left.AdditionalProperties == right.AdditionalProperties - && left.ErrorStyle == right.ErrorStyle - && AdditionalSchemasEqual(left, right) - && left.Properties.Count == right.Properties.Count - && left.Properties.Zip(right.Properties).All(static pair => PropertiesEqual(pair.First, pair.Second)) - && left.LiteralMarkers.Count == right.LiteralMarkers.Count - && left.LiteralMarkers.Zip(right.LiteralMarkers).All(static pair => MarkersEqual(pair.First, pair.Second)); - - private static bool AdditionalSchemasEqual(ObjectNode left, ObjectNode right) => - (left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema) switch - { - (null, null) => true, - (not null, not null) => DeepEquals(left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema), - _ => false, - }; - - private static bool PropertiesEqual(SpecProperty left, SpecProperty right) => - string.Equals(left.Name, right.Name, StringComparison.Ordinal) - && left.IsRequired == right.IsRequired - && DeepEquals(left.Schema, right.Schema); - - private static bool MarkersEqual(LiteralMarker left, LiteralMarker right) => - string.Equals(left.PropertyName, right.PropertyName, StringComparison.Ordinal) - && left.Kind == right.Kind - && string.Equals(left.Value, right.Value, StringComparison.Ordinal); - - private static bool SequenceEquals(IReadOnlyList left, IReadOnlyList right) => - left.Count == right.Count && left.Zip(right).All(static pair => DeepEquals(pair.First, pair.Second)); + if (!left.Target.Contains('#', StringComparison.Ordinal) + || !right.Target.Contains('#', StringComparison.Ordinal)) + { + return false; + } + + // A revisited pair is a cycle inside a comparison that has not failed yet. + if (!_visitedPairs.Add($"{left.Target}\0{right.Target}")) + { + return true; + } + + return _graph.TryGetValue(left.Target, out var leftTarget) + && _graph.TryGetValue(right.Target, out var rightTarget) + && NodesEqual(leftTarget, rightTarget); + } + + private bool ObjectsEqual(ObjectNode left, ObjectNode right) => + left.AdditionalProperties == right.AdditionalProperties + && left.ErrorStyle == right.ErrorStyle + && AdditionalSchemasEqual(left, right) + && left.Properties.Count == right.Properties.Count + && left.Properties.Zip(right.Properties).All(pair => PropertiesEqual(pair.First, pair.Second)) + && left.LiteralMarkers.Count == right.LiteralMarkers.Count + && left.LiteralMarkers.Zip(right.LiteralMarkers).All(static pair => MarkersEqual(pair.First, pair.Second)); + + private bool AdditionalSchemasEqual(ObjectNode left, ObjectNode right) => + (left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema) switch + { + (null, null) => true, + (not null, not null) => NodesEqual(left.AdditionalPropertiesSchema, right.AdditionalPropertiesSchema), + _ => false, + }; + + private bool PropertiesEqual(SpecProperty left, SpecProperty right) => + string.Equals(left.Name, right.Name, StringComparison.Ordinal) + && left.IsRequired == right.IsRequired + && NodesEqual(left.Schema, right.Schema); + + private static bool MarkersEqual(LiteralMarker left, LiteralMarker right) => + string.Equals(left.PropertyName, right.PropertyName, StringComparison.Ordinal) + && left.Kind == right.Kind + && string.Equals(left.Value, right.Value, StringComparison.Ordinal); + + private bool SequencesEqual(IReadOnlyList left, IReadOnlyList right) => + left.Count == right.Count && left.Zip(right).All(pair => NodesEqual(pair.First, pair.Second)); + } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 228baba4..6dea2ddc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -33,14 +33,16 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera _curationValidator.Validate(document, selected, reachable, curation, errors); // Aliases are validated against the raw document above; the collapse happens before - // names resolve so the rest of the pipeline never sees the duplicates. + // names resolve so the rest of the pipeline never sees the duplicates. Reachability + // recollects on the transformed document because an alias target's promoted inline + // children only become reachable through the rewritten references. var aliased = _schemaAliases.Apply(document, curation.SchemaAliases); if (!ReferenceEquals(aliased, document)) { document = aliased; var aliasedById = document.Operations.ToDictionary(static operation => operation.OperationId, StringComparer.Ordinal); selected = Array.AsReadOnly([.. selected.Select(operation => aliasedById[operation.OperationId])]); - reachable = MapReachableKeys(reachable, curation.SchemaAliases); + reachable = _reachableSchemas.Collect(document, selected, errors); curation = curation with { PropertyOverrides = MapOverrideKeys(curation.PropertyOverrides, curation.SchemaAliases, errors), @@ -176,7 +178,8 @@ private static RegistryPlan ComposeRegistry(RegistryPlan schemaRegistry, IReadOn private static ReadOnlyCollection MapOverrideKeys(IReadOnlyList overrides, IReadOnlyList aliases, BindingErrorCollector errors) { - // Tolerant of duplicated sources for the same reason as MapReachableKeys. + // Tolerant of duplicated sources: the validator has already recorded them and the + // batched failure throws before any plan leaves this bind. var map = new Dictionary(StringComparer.Ordinal); foreach (var alias in aliases) { @@ -204,33 +207,6 @@ private static ReadOnlyCollection MapOverrideKeys(IReadOnlyLis return Array.AsReadOnly([.. canonical]); } - /// - /// The post-collapse reachable set follows from the raw one mechanically: aliased keys - /// vanish and their targets take their place, so no second traversal (and no duplicated - /// traversal diagnostics) is needed. - /// - private static ReachableSchemaSet MapReachableKeys(ReachableSchemaSet reachable, IReadOnlyList aliases) - { - // Tolerant of duplicated sources: the validator has already recorded them and the - // batched failure throws before any plan leaves this bind. - var map = new Dictionary(StringComparer.Ordinal); - foreach (var alias in aliases) - { - map[alias.Schema] = alias.AliasOf; - } - return new ReachableSchemaSet - { - GraphKeys = [.. reachable.GraphKeys - .Select(key => map.GetValueOrDefault(key, key)) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal)], - ResponseRootKeys = [.. reachable.ResponseRootKeys - .Select(key => map.GetValueOrDefault(key, key)) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal)], - }; - } - private static ReadOnlyCollection SelectOperations(OperationSelection selection, Dictionary operationsById, BindingErrorCollector errors) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs index f634f956..928dd8aa 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs @@ -76,10 +76,14 @@ private static IEnumerable EmitParameters(OperationPlan operati private static InvocationExpressionSyntax EmitDelegation(OperationPlan operation) { + // HttpMethod.Patch is absent from the downlevel BCL, so it rides the internal spine. + var verbContainer = string.Equals(operation.HttpMethod, "patch", StringComparison.Ordinal) + ? "OpenCodeHttpMethod" + : "HttpMethod"; var arguments = new List { SyntaxFactory.Argument(EmissionSyntax.MemberAccess( - SyntaxFactory.IdentifierName("HttpMethod"), + SyntaxFactory.IdentifierName(verbContainer), CSharpNamePolicy.ToPascalCase(operation.HttpMethod))), SyntaxFactory.Argument(EmitRoute(operation)), }; diff --git a/tools/curation.json b/tools/curation.json index 6fbbac58..8ee4fae1 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -14,6 +14,12 @@ "clientName": "Sessions", "handleName": "SessionClient", "handleParameter": "sessionID" + }, + "shell": { + "placement": "client", + "clientName": "Shells", + "handleName": "ShellClient", + "handleParameter": "id" } }, "envelopePayloadNames": {}, @@ -36,6 +42,11 @@ "schema": "InvalidRequestError1", "aliasOf": "InvalidRequestError", "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." + }, + { + "schema": "Shell.Info1", + "aliasOf": "Shell.Info", + "reason": "Upstream spec-gen emits a structurally identical duplicate of the shell info schema." } ] } diff --git a/tools/generation-profile.txt b/tools/generation-profile.txt index 83e81290..e1545bc4 100644 --- a/tools/generation-profile.txt +++ b/tools/generation-profile.txt @@ -4,3 +4,10 @@ v2.session.create v2.session.get v2.session.list v2.session.message +v2.session.remove +v2.session.rename +v2.shell.create +v2.shell.get +v2.shell.list +v2.shell.remove +v2.shell.timeout From f2a265bd70bbd9baa1e5b66a6a8c495b475873e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 16 Aug 2026 00:07:12 +0300 Subject: [PATCH 08/13] feat: refuse curated mutually-exclusive query pairs at the route boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifth curation section declares two query parameters one operation's server refuses to combine — spec prose the binder cannot derive. The validator owns the row shape (pairs only, reason required, selected-profile membership), the binder pins each name to the bound query surface and records the pair on the plan, the route builder throws ArgumentException before any request is built, and message.list's order+cursor pair rides the first row with a no-send contract test. Closes #28. --- .../2026-08-15-m2-second-breadth-batch.md | 5 ++- src/OpenCode.Sdk/OpenCodeRoutes.cs | 5 +++ .../Sessions/SessionClientContractTests.cs | 17 +++++++++ .../Fixtures/Binding/alias-curation.json | 3 +- .../Fixtures/Binding/valid-curation.json | 3 +- .../Binding/OperationPlanBinderTests.cs | 37 +++++++++++++++++++ ...roduce_Escaped_Route_Builders.verified.txt | 5 +++ .../Support/BindingScenarioData.cs | 12 +++++- .../Support/EmitterPlanFixture.cs | 4 ++ .../Support/GenerationTestData.cs | 3 +- .../Generator/Binding/CurationValidator.cs | 35 ++++++++++++++++++ .../Binding/Models/ExclusiveQueryPairPlan.cs | 9 +++++ .../Binding/Models/GenerationCuration.cs | 11 ++++++ .../Binding/Models/MutuallyExclusiveQuery.cs | 27 ++++++++++++++ .../Binding/Models/QueryRequestPlan.cs | 11 ++++++ .../Generator/Binding/OperationPlanBinder.cs | 30 +++++++++++++++ .../Generator/Emission/RoutesEmitter.cs | 37 ++++++++++++++++++- tools/curation.json | 7 ++++ 18 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ExclusiveQueryPairPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/MutuallyExclusiveQuery.cs diff --git a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md index 01024f39..fc6bd28c 100644 --- a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md @@ -76,7 +76,10 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi **`v2.shell.output` deferred:** its inline data object and integer cursor/limit query params each need a mechanism no other admitted operation needs; it returns with a later batch. -- [ ] #28 rider end to end. +- [x] #28 rider end to end: the `mutuallyExclusiveQueries` curation section (pairs only, + fail-closed validated), the binder pins each name to the bound query surface, the + route builder refuses the pair before any request, and the no-send contract test + observes it on `message.list`. - [ ] Live demo against `opencode2 serve`: shell create → list → get → output → remove, plus session rename → remove. - [ ] ROADMAP + this plan updated in the same commits. diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index 9416c6fe..0bac5dc3 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -100,6 +100,11 @@ public static string ListMessages(string sessionId, MessageListRequest? request return path; } + if (request.Order is not null && request.Cursor is not null) + { + throw new ArgumentException("The 'order' and 'cursor' query values cannot combine.", nameof(request)); + } + var query = new QueryStringBuilder(); query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index 3b431353..f32d9472 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -137,6 +137,23 @@ public async Task ListMessagesAsync_Should_Return_The_500_Error_On_The_NoThrow_S await Assert.That(response.RawBody).Contains("boom"); } + [Test] + public async Task ListMessagesAsync_Should_Refuse_Order_Combined_With_Cursor_Before_Sending() + { + using var scenario = ContractScenario.Responding(); + + var exception = await Assert + .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListRequest + { + Order = ListOrder.Ascending, + Cursor = "cur_1", + })) + .Throws(); + + await Assert.That(exception!.ParamName).IsEqualTo("request"); + await Assert.That(scenario.Requests).IsEmpty(); + } + [Test] public async Task RemoveSessionAsync_Should_Treat_The_204_As_A_Bodiless_Success() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json index 08843da7..c23a6b6a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json @@ -12,5 +12,6 @@ "aliasOf": "InvalidRequestError", "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." } - ] + ], + "mutuallyExclusiveQueries": [] } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json index 92753efb..548744ec 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json @@ -6,5 +6,6 @@ }, "envelopePayloadNames": {}, "propertyOverrides": [], - "schemaAliases": [] + "schemaAliases": [], + "mutuallyExclusiveQueries": [] } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index c1ce574b..c9b66d21 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -813,6 +813,43 @@ public async Task Bind_Should_Refuse_An_Object_Query_Parameter() await AssertWidgetRefusalAsync(document, "unsupported schema shape"); } + [Test] + public async Task Bind_Should_Record_A_Curated_Mutually_Exclusive_Query_Pair() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.widget.list"), + Curation( + Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), + mutuallyExclusiveQueries: [ExclusiveQuery("v2.widget.list", "order", "cursor")])); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + var pair = list.QueryRequest!.MutuallyExclusivePairs.Single(); + await Assert.That(pair.FirstWireName).IsEqualTo("order"); + await Assert.That(pair.SecondWireName).IsEqualTo("cursor"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Mutually_Exclusive_Row_Naming_An_Absent_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.list"), + Curation( + Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), + mutuallyExclusiveQueries: [ExclusiveQuery("v2.widget.list", "order", "cursor")]))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("does not carry query parameter", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Merge_A_Location_Query_Into_The_Request_Body_Model() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt index e3814b8c..aa8501f2 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RoutesEmitterTests.Emit_Should_Produce_Escaped_Route_Builders.verified.txt @@ -136,6 +136,11 @@ public static class OpenCodeRoutes return path; } + if (request.Order is not null && request.Cursor is not null) + { + throw new ArgumentException("The 'order' and 'cursor' query values cannot combine.", nameof(request)); + } + var query = new QueryStringBuilder(); query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs index d317d703..fe893519 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs @@ -14,13 +14,23 @@ public static GenerationCuration Curation( IReadOnlyDictionary groups, IReadOnlyDictionary? envelopePayloadNames = null, IReadOnlyList? propertyOverrides = null, - IReadOnlyList? schemaAliases = null) => + IReadOnlyList? schemaAliases = null, + IReadOnlyList? mutuallyExclusiveQueries = null) => new() { Groups = groups, EnvelopePayloadNames = envelopePayloadNames ?? new Dictionary(StringComparer.Ordinal), PropertyOverrides = propertyOverrides ?? [], SchemaAliases = schemaAliases ?? [], + MutuallyExclusiveQueries = mutuallyExclusiveQueries ?? [], + }; + + public static MutuallyExclusiveQuery ExclusiveQuery(string operation, string first, string second) => + new() + { + Operation = operation, + Parameters = [first, second], + Reason = "The pinned server refuses the pair.", }; public static SchemaAlias Alias(string schema, string aliasOf, string reason = "The upstream spec emits a duplicate component.") => diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 7f31fcaf..8d82f77d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -251,6 +251,10 @@ private static OperationPlan CreateWidgetListOperation() => QueryProperty("parentID", "ParentId", QueryValueKind.SessionParentFilter), QueryProperty("location", "Location", QueryValueKind.Location), ], + MutuallyExclusivePairs = + [ + new ExclusiveQueryPairPlan { FirstWireName = "order", SecondWireName = "cursor", }, + ], }, Envelope = new EnvelopePlan { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs index 321752b4..45d177fa 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs @@ -24,7 +24,8 @@ internal static class GenerationTestData }, "envelopePayloadNames": {}, "propertyOverrides": [], - "schemaAliases": [] + "schemaAliases": [], + "mutuallyExclusiveQueries": [] } """; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index 1599c54e..ba5ece81 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -24,6 +24,7 @@ public void Validate(SpecDocument document, IReadOnlyList selecte ValidateEnvelopeNames(selectedIds, documentIds, curation, errors); ValidatePropertyOverrides(document, reachable, curation, errors); ValidateSchemaAliases(document, reachable, curation, errors); + ValidateMutuallyExclusiveQueries(selectedIds, documentIds, curation, errors); } private static void ValidateGroups(IReadOnlyList selected, HashSet selectedGroups, @@ -220,6 +221,40 @@ private static void ValidatePropertyOverrides(SpecDocument document, ReachableSc /// a dereferenced source goes dormant, and any structural divergence — the tag included — /// breaks the identity check. Every upstream move on the duplicate is loud. /// + /// + /// The mutual-exclusion relation lives only in spec prose, so a curated row carries it; + /// pairs only — larger groups wait for an operation that needs one. + /// + private static void ValidateMutuallyExclusiveQueries(HashSet selectedIds, + HashSet documentIds, GenerationCuration curation, BindingErrorCollector errors) + { + foreach (var row in curation.MutuallyExclusiveQueries) + { + if (string.IsNullOrWhiteSpace(row.Reason)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must state a reason"); + } + + if (!documentIds.Contains(row.Operation)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation absent from the spec"); + continue; + } + + if (!selectedIds.Contains(row.Operation)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation outside the selected profile"); + } + + if (row.Parameters.Count is not 2 + || row.Parameters.Distinct(StringComparer.Ordinal).Count() is not 2 + || row.Parameters.Any(string.IsNullOrWhiteSpace)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must name exactly two distinct parameters"); + } + } + } + private static void ValidateSchemaAliases(SpecDocument document, ReachableSchemaSet reachable, GenerationCuration curation, BindingErrorCollector errors) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ExclusiveQueryPairPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ExclusiveQueryPairPlan.cs new file mode 100644 index 00000000..d5f76718 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ExclusiveQueryPairPlan.cs @@ -0,0 +1,9 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// A curated pair of query parameters the route builder refuses to combine. +internal sealed record ExclusiveQueryPairPlan +{ + public required string FirstWireName { get; init; } + + public required string SecondWireName { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs index 3e2895f7..e253e5be 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs @@ -48,4 +48,15 @@ public required IReadOnlyList SchemaAliases field = Array.AsReadOnly([.. value]); } } = Array.AsReadOnly(Array.Empty()); + + [JsonPropertyName("mutuallyExclusiveQueries")] + public required IReadOnlyList MutuallyExclusiveQueries + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/MutuallyExclusiveQuery.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/MutuallyExclusiveQuery.cs new file mode 100644 index 00000000..8239efb0 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/MutuallyExclusiveQuery.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// +/// Declares two query parameters of one operation the server refuses to combine; the +/// relation lives only in the spec's prose, so it cannot be derived mechanically. +/// +internal sealed record MutuallyExclusiveQuery +{ + [JsonPropertyName("operation")] + public required string Operation { get; init; } + + [JsonPropertyName("parameters")] + public required IReadOnlyList Parameters + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + + [JsonPropertyName("reason")] + public required string Reason { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs index 3e0b4fec..1e10e27b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs @@ -13,6 +13,17 @@ internal sealed record QueryRequestPlan /// public bool RidesRequestBody { get; init; } + /// Gets the curated query pairs the route builder refuses to combine. + public IReadOnlyList MutuallyExclusivePairs + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); + /// Gets every bound query parameter in wire order, including base-inherited ones. public required IReadOnlyList Properties { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 85bb6a19..9d7e0f4b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -943,9 +943,39 @@ private int TemplatePosition(string wireName) TypeName = OperationNamePolicy.RequestTypeName(_operation), DerivesFromListRequest = properties.Count > 0 && properties[0].IsInherited, Properties = properties, + MutuallyExclusivePairs = BindExclusivePairs(properties), }; } + /// The validator owns the row shape; this wall pins each name to the bound query surface. + private List BindExclusivePairs(List properties) + { + var pairs = new List(); + foreach (var parameters in _curation.MutuallyExclusiveQueries + .Where(row => string.Equals(row.Operation, _operation.OperationId, StringComparison.Ordinal)) + .Select(static row => row.Parameters)) + { + var missing = parameters.FirstOrDefault(parameter => + !properties.Any(property => string.Equals(property.WireName, parameter, StringComparison.Ordinal))); + if (missing is not null) + { + Refuse($"the operation does not carry query parameter '{missing}' named by its mutually-exclusive row"); + continue; + } + + if (parameters.Count is 2) + { + pairs.Add(new ExclusiveQueryPairPlan + { + FirstWireName = parameters[0], + SecondWireName = parameters[1], + }); + } + } + + return pairs; + } + /// The one admitted deep-object encoding is the optional nullable location selector. private QueryPropertyPlan? BindLocationSelector(SpecParameter parameter) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs index 88fad1b5..7c7d4c98 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -153,13 +153,18 @@ private static IEnumerable EmitQueryComposition(OperationPlan o SyntaxFactory.IdentifierName("request"), SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), SyntaxFactory.Block(SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName("path")))); + foreach (var pair in operation.QueryRequest!.MutuallyExclusivePairs) + { + yield return EmitExclusivePairGuard(operation.QueryRequest, pair); + } + yield return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration( SyntaxFactory.IdentifierName("var"), SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("query") .WithInitializer(SyntaxFactory.EqualsValueClause(SyntaxFactory.ObjectCreationExpression( TypeSyntaxEmitter.EmitNamed("QueryStringBuilder")) .WithArgumentList(SyntaxFactory.ArgumentList())))))); - foreach (var property in operation.QueryRequest!.Properties) + foreach (var property in operation.QueryRequest.Properties) { var arguments = new List { @@ -187,6 +192,36 @@ private static IEnumerable EmitQueryComposition(OperationPlan o EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), "Value"))); } + /// The server refuses the curated pair, so the route boundary refuses it first. + private static IfStatementSyntax EmitExclusivePairGuard(QueryRequestPlan queryRequest, ExclusiveQueryPairPlan pair) + { + var first = queryRequest.Properties.Single(property => + string.Equals(property.WireName, pair.FirstWireName, StringComparison.Ordinal)); + var second = queryRequest.Properties.Single(property => + string.Equals(property.WireName, pair.SecondWireName, StringComparison.Ordinal)); + return SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.LogicalAndExpression, + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("request"), first.PropertyName), + SyntaxFactory.UnaryPattern(SyntaxFactory.ConstantPattern( + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))), + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("request"), second.PropertyName), + SyntaxFactory.UnaryPattern(SyntaxFactory.ConstantPattern( + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))))), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( + TypeSyntaxEmitter.EmitNamed("ArgumentException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(StringLiteral( + $"The '{pair.FirstWireName}' and '{pair.SecondWireName}' query values cannot combine.")), + SyntaxFactory.Argument(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("request")))), + ])))))); + } + private static string QueryAddMethod(QueryValueKind kind) => kind switch { QueryValueKind.Text => "AddText", diff --git a/tools/curation.json b/tools/curation.json index 8ee4fae1..75222400 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -48,5 +48,12 @@ "aliasOf": "Shell.Info", "reason": "Upstream spec-gen emits a structurally identical duplicate of the shell info schema." } + ], + "mutuallyExclusiveQueries": [ + { + "operation": "v2.message.list", + "parameters": ["order", "cursor"], + "reason": "The pinned message-list contract forbids combining cursor with order and the server refuses the pair with InvalidCursorError; known invariants refuse at the route boundary." + } ] } From e97d72c698d90fe424a185b80c7b2075b4c37f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 16 Aug 2026 00:08:43 +0300 Subject: [PATCH 09/13] docs: record the second breadth batch completion and its live demo --- docs/ROADMAP.md | 21 +++++++++++++++---- .../2026-08-15-m2-second-breadth-batch.md | 8 ++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0ff3ea9f..e04dfd86 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,11 +68,24 @@ by the SDK test project; the Extensions tests are registration-topology only). 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), and the location + merged-Request design is +machinery is deleted — closing #31 by construction. #32 and #33 stay sealed with their +execution homes recorded (Session 24), and the location + merged-Request design is sealed (Session 25, Q93/Q94: the binder placement map and the dual-channel location -rendering) — the body+query admission stop condition is lifted. Remaining M2 breadth -batches interleave next against the sealed design. +rendering). + +**The M2 second breadth batch is complete** (plan: +`superpowers/plans/2026-08-15-m2-second-breadth-batch.md`) — the design-prover batch: +`session.remove`/`session.rename` and the `Shells` family +(`list`/`create`/`get`/`remove`/`timeout`) ride the 204 no-content and +`{location, data}` envelope machinery, the deepObject `LocationSelector` query channel, +the merged body+query request models, the ambient options location riding the +middleware headers, and the first PATCH/DELETE verbs. #28 landed as the +`mutuallyExclusiveQueries` curation section with the route-boundary refusal on +`message.list`. Demonstrated live 2026-08-16 against `opencode2 serve` +v0.0.0-next-17403 (shell create → get → timeout → remove and session rename → remove, +204s typed, ambient location echoed). `v2.shell.output` deferred to a later batch — +its inline data object and integer cursor query params each need a mechanism of their +own. 13 operations selected, 107 pending. ## Milestones diff --git a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md index fc6bd28c..74bf849f 100644 --- a/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-15-m2-second-breadth-batch.md @@ -80,6 +80,8 @@ introduced gets exercised by a real operation. Sealed inputs: research log Sessi fail-closed validated), the binder pins each name to the bound query surface, the route builder refuses the pair before any request, and the no-send contract test observes it on `message.list`. -- [ ] Live demo against `opencode2 serve`: shell create → list → get → output → remove, - plus session rename → remove. -- [ ] ROADMAP + this plan updated in the same commits. +- [x] Live demo against `opencode2 serve` v0.0.0-next-17403 (2026-08-16): shell + create → list → get → timeout → remove (204), session rename → remove (204), all + typed; the ambient options location rode the header channel and came back in the + location echo. (`output` deferred with its operation.) +- [x] ROADMAP + this plan updated in the same commits. From 431293bf9fe7e533cb48615b4e266b49a573112a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 16 Aug 2026 12:12:16 +0300 Subject: [PATCH 10/13] fix(sdk): percent-encode the ambient location directory header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server decodes the directory header (decodeURIComponent with a raw fallback) while reading the workspace header verbatim, so the escaping mirrors that asymmetry exactly. Measured against a live server whose cwd differed from both probe targets: a directory literally named 'loc%20test' returns HTTP 500 with an empty body when the header rides raw — the server decodes '%20' into a space and resolves a directory that does not exist — and returns 200 with the correct location when the value is escaped. Escaping also keeps a non-ASCII path sendable at all, since header values cannot carry it raw. --- src/OpenCode.Sdk/Internal/Pipeline.cs | 11 +++--- tests/OpenCode.Sdk.Tests/PipelineTests.cs | 42 +++++++++++++++++++++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index feaa92c0..e1564ccc 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -212,12 +212,15 @@ private void Decorate(HttpRequestMessage request) request.Headers.Authorization = _authorization; } - // The ambient location rides the middleware's header channel; the values are user - // paths, so they go on the wire verbatim. The server resolves any explicit - // per-request location query first, so no client-side merge exists. + // The ambient location rides the middleware's header channel, and the two members + // travel differently: the server percent-decodes the directory header but reads the + // workspace one verbatim, so the escaping mirrors that asymmetry exactly. Escaping + // also keeps a non-ASCII path sendable, since header values cannot carry it raw. The + // server resolves any explicit per-request location query first, so no client-side + // merge exists. if (_location?.Directory is { } directory) { - _ = request.Headers.TryAddWithoutValidation("x-opencode-directory", directory); + _ = request.Headers.TryAddWithoutValidation("x-opencode-directory", Uri.EscapeDataString(directory)); } if (_location?.Workspace is { } workspace) diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 366f6fab..6c889c96 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -165,7 +165,7 @@ public async Task ExecuteAsync_Should_Decorate_The_Ambient_Location_Headers() _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); var request = handler.Requests.Single(); - await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("/repo"); + await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("%2Frepo"); await Assert.That(request.Headers["x-opencode-workspace"]).IsEqualTo("wrk_1"); } @@ -179,10 +179,46 @@ public async Task ExecuteAsync_Should_Omit_An_Unset_Location_Member() _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); var request = handler.Requests.Single(); - await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("/repo"); + await Assert.That(request.Headers["x-opencode-directory"]).IsEqualTo("%2Frepo"); await Assert.That(request.Headers.ContainsKey("x-opencode-workspace")).IsFalse(); } + [Test] + public async Task ExecuteAsync_Should_Escape_A_Directory_Whose_Name_Contains_A_Percent_Sequence() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector + { + Directory = "/data/reports%20q3", + }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + // The server decodes this header, so an unescaped '%20' would arrive as a space and + // silently address a different directory. + await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]) + .IsEqualTo("%2Fdata%2Freports%2520q3"); + } + + [Test] + public async Task ExecuteAsync_Should_Send_A_Non_Ascii_Directory_As_Ascii() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient, location: new LocationSelector + { + Directory = "/home/deniz/işler", + }); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + // Header values above Latin-1 are unsendable, so the escape is what keeps a + // non-ASCII path on the wire at all. + await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]) + .IsEqualTo("%2Fhome%2Fdeniz%2Fi%C5%9Fler"); + } + [Test] public async Task ExecuteAsync_Should_Omit_The_Location_Headers_Without_An_Ambient_Location() { @@ -212,7 +248,7 @@ public async Task ExecuteAsync_Should_Snapshot_The_Ambient_Location_At_Construct _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); - await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]).IsEqualTo("/before"); + await Assert.That(handler.Requests.Single().Headers["x-opencode-directory"]).IsEqualTo("%2Fbefore"); } [Test] From 3d924fe17e7babced2e4270ee6c9c5245120080f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 16 Aug 2026 12:45:59 +0300 Subject: [PATCH 11/13] docs: record the location-channel tour and correct the ambient documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session 26 (Q95) records what the upstream sweep and the live probes found: the header channel is not deprecated — it was never expressible in a schema derived from declared endpoint parameters, the same reason Authorization is absent — precedence resolves per field rather than per location object, and the middleware is attached per group so the ambient channel is inert on session-scoped and unmiddlewared operations. The options documentation now states both limits instead of promising a blanket override; doc 15 gains the mechanics; the ambient channel's future is anchored at M5 as #37, where packaging unblocking makes it the last free moment to change the surface. --- docs/ROADMAP.md | 8 ++- docs/research/00-research-log.md | 67 ++++++++++++++++++++++ docs/research/15-opencode-v2-platform.md | 33 ++++++++--- src/OpenCode.Sdk/IOpenCodeClientOptions.cs | 2 +- src/OpenCode.Sdk/OpenCodeClientOptions.cs | 13 +++-- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e04dfd86..a9cab7c7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -125,7 +125,13 @@ is revisited at each milestone boundary. background service's discovery file are candidate mechanisms — decided in the M4 plan; platform detail: research doc 15.) 5. **M5 — Full surface.** Complete generation profile over the protocol surface, - exclusion fingerprints (ADR-0008), packaging unblocked. + exclusion fingerprints (ADR-0008), packaging unblocked. The **ambient location + header decision (#37)** lands here and is decision-first: packaging unblocking + freezes the public surface, so this is the last free moment to drop + `OpenCodeClientOptions.Location` (option B) or fold it into the query channel + (option C). The batch admitting `project.list` / `permission.saved.*` / + `session.form.*` answers whether the header is those operations' only addressing + channel. 6. **M6 — Operational closure.** `refresh-spec`, retry/telemetry/hooks, quarantine lane, nightly canary (the performance suite joins it); durable decisions distill into ADRs and the `superpowers/` documents retire. Any diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index 255a4192..8ee9d248 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -2023,3 +2023,70 @@ per-request `Location` wins over the ambient default because the server resolves before header — the SDK performs no client-side merge. `v2.session.list`'s flat location fields bind as ordinary query properties with no special case; the fail-closed walls stay. + +# Session 26 — 2026-08-16: Q95 location-channel tour, measured + +The maintainer challenged the ambient location header mid-batch ("deprecate olmuştu, +şemada da yok artık"). The claim was investigated against upstream rather than argued, +the batch's encoding defect was found and fixed as a result, and the ambient channel's +future became an M5 decision (#37). + +## Q95: Is the `x-opencode-directory` header deprecated, and how does location actually resolve? + +**How researched:** three parallel primary-source sweeps over `external/opencode` at the +spec pin (`a6a712a`) — the generated JS SDK, the server/core internals, and every +consumer — plus live probes against `opencode2 serve` v0.0.0-next-17403 started in a +directory distinct from every probe target. + +**Found — not deprecated, and never removed from the schema:** + +- The submodule sits exactly on the spec pin, and upstream `v2` is 164 commits ahead of + it; at that live HEAD `packages/server/src/location.ts:31,34` still reads both headers, + still `decodeURIComponent`s the directory one, and **no commit has touched the file + since the pin**. No deprecation marker exists anywhere in `packages/server` or + `packages/core`. +- The header was never expressible in the document, so it was never scrubbed from it. The + spec is `OpenApi.fromApi(ClientApi)` (`packages/protocol/script/generate-openapi.ts`), a + 16-line script with no filtering, and that pipeline derives parameters only from declared + endpoint schemas. Headers *are* expressible there, but no protocol endpoint declares + `headers:` at all; the location headers are read imperatively inside middleware, which + contributes nothing to the parameter surface. **Control case: `Authorization` is invisible + the same way** — the server genuinely requires Basic auth while the generated document + carries empty `securitySchemes`. +- Git history rules out replacement: `location.ts` was created in `56a37c3640` with the + header *and* query branches already present, so the query channel was additive from day + one. `specs/v2/schema-changelog.md` frames it as preserving header routing for + compatibility. + +**Found — the channels are not interchangeable:** + +- `LocationMiddleware` is attached **per group** (`packages/protocol/src/api.ts:150-180`) + and applies to every endpoint in that group regardless of whether it declares the query + parameter. So the header reaches operations the query channel cannot (`project.list`, + `permission.saved.*`), and is inert on groups without it (health, server, message, + event, debug, migration) and on session-scoped endpoints, which resolve location from + the session DB row and ignore both channels. +- **Precedence is per field, not per location object** (measured): query beats header beats + `process.cwd()`, but `directory` and `workspaceID` resolve independently and the code uses + `||`, so an empty query value falls through to the header. Consequently an ambient + `{directory, workspaceID}` plus a per-request query carrying only `directory` resolves to + the per-request directory **and the ambient workspace** — a per-request location does not + wholesale-replace the ambient one. +- No first-party client *library* offers an ambient location: `ClientOptions` is + `{baseUrl, fetch, headers}` and every UI (app/web, TUI, CLI) threads location per call + through the query channel. Only `packages/drive`, a single-directory test harness, pins an + ambient header — the same shape this SDK ships. + +**Found — a defect in this batch:** the ambient directory header rode the wire raw. Measured +against the live server, a directory literally named `loc%20test` returns **HTTP 500 with an +empty body** when sent raw (the server decodes `%20` into a space and resolves a directory +that does not exist) and **200 with the correct location** when percent-encoded. The +workspace header is *not* decoded server-side, so the escaping is asymmetric by contract. + +**Decisions (maintainer, sealed):** the ambient channel stays for now (option A) with its +documentation corrected to state per-field precedence and the coverage boundary; the +directory header is percent-encoded and the workspace header stays verbatim, mirroring the +server. Whether to drop the ambient channel entirely (option B — "convenience does not +justify supporting a channel outside the public contract"; the MCP server will set location +explicitly anyway) or fold it into the query channel (option C) is anchored at **M5 as +issue #37**, decision-first because packaging unblocking freezes the public surface. diff --git a/docs/research/15-opencode-v2-platform.md b/docs/research/15-opencode-v2-platform.md index 78facee4..d957de44 100644 --- a/docs/research/15-opencode-v2-platform.md +++ b/docs/research/15-opencode-v2-platform.md @@ -197,13 +197,32 @@ pinned server implementation. Client-side password resolution lives in the *cons reads no environment. Service mode generates `randomBytes(32).toString("base64url")` when unconfigured; foreground `serve` prints the password when it generated one. -**Location addressing is dual-channel (re-derived at the pin, 2026-08-14)**: the server's -location middleware (`packages/server/src/location.ts`) resolves per request as -`location[workspace]` query **or** `x-opencode-workspace` header, and `location[directory]` -query **or** `x-opencode-directory` header **or** the server's cwd — precedence query > -header. The per-operation `location` deepObject query parameter (61 operations, §5a) is the -spec-visible channel the first-party generated client uses exclusively; the headers are the -spec-invisible ambient channel other consumers (drive driver, CLI) ride. The SDK-side +**Location addressing is dual-channel (re-derived at the pin, 2026-08-14; measured +2026-08-16 — research log Q95)**: the server's location middleware +(`packages/server/src/location.ts`) resolves per request as `location[workspace]` query +**or** `x-opencode-workspace` header, and `location[directory]` query **or** +`x-opencode-directory` header **or** the server's cwd — precedence query > header. Three +mechanics matter and are easy to get wrong: + +- **Precedence is per field, not per location object**, and the code uses `||`, so an empty + query value falls through to the header. An ambient `{directory, workspaceID}` plus a + per-request query carrying only `directory` resolves to the per-request directory and the + **ambient** workspace. +- **The middleware is attached per group** (`packages/protocol/src/api.ts:150-180`), not per + endpoint that declares the parameter. It therefore reaches operations with no location + query parameter at all (`project.list`, `permission.saved.*`) and is inert on the groups + without it (health, server, message, event, debug, migration) and on session-scoped + endpoints, which resolve location from the session DB row and ignore both channels. +- **Only the directory header is percent-decoded** server-side; the workspace header is read + verbatim, so a client's escaping must be asymmetric to match. + +The headers are absent from the document because they were never expressible in it, not +because they were removed: the spec is `OpenApi.fromApi(ClientApi)` over declared endpoint +schemas, and middleware contributes nothing to the parameter surface — `Authorization` is +invisible for exactly the same reason while remaining mandatory. The per-operation `location` +deepObject query parameter (61 operations, §5a) is the spec-visible channel the first-party +generated client uses exclusively; the headers are the spec-invisible ambient channel other +consumers (drive driver, CLI) ride. The SDK-side rendering — per-op request property vs ambient client/request-option default, plus the deepObject marshalling and the `session.list` flat-field exception — is the location + merged-Request design session that opens M3 planning (research log Session 22). diff --git a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs index 07dbdf59..f08b1e46 100644 --- a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs @@ -16,6 +16,6 @@ public interface IOpenCodeClientOptions /// Gets the HTTP basic-authentication password; sends anonymous requests. public string? Password { get; } - /// Gets the ambient location every request carries as location headers, or for none. + /// Gets the ambient location sent on every request as location headers, or for none. public LocationSelector? Location { get; } } diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index 73970539..cfe741f3 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -25,11 +25,14 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions public string? Password { get; set; } /// - /// Gets or sets the ambient location every request carries as - /// x-opencode-directory/x-opencode-workspace headers; - /// leaves the server's own resolution in place. An explicit - /// per-request location rides the query channel and wins — the server resolves query - /// before header. + /// Gets or sets the ambient location, sent on every request as the + /// x-opencode-directory and x-opencode-workspace headers; + /// leaves the server's own resolution in place. The server + /// honors these headers only on the operations whose group resolves location from the + /// request — operations that resolve it from a session instead, and those that do not + /// resolve it at all, ignore them. A per-request location travels on the query channel + /// and takes precedence per member: it overrides only the members it sets, and + /// the rest still come from here. /// public LocationSelector? Location { get; set; } } From e9a61f3026d664cc1f70150c7f7b34c5e48e825f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 16 Aug 2026 12:49:35 +0300 Subject: [PATCH 12/13] refactor: restore member ordering in the query builder and curation validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryStringBuilder's private block reads in first-use order again — Append, reached from AddText, precedes the bracketed variant AddLocation reaches. In CurationValidator the new mutual-exclusion validator had landed between the alias walls' documentation and the method it describes, leaving one method with two summaries and the other with none; it now sits last among the privates, matching its position in the Validate call sequence. --- .../Internal/QueryStringBuilder.cs | 18 ++--- .../Generator/Binding/CurationValidator.cs | 68 +++++++++---------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs index c9ba0e7f..da5426a0 100644 --- a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -81,27 +81,27 @@ public void AddLocation(string name, LocationSelector? value) } } - /// - /// DeepObject keys ride the wire with literal brackets, matching the first-party - /// client's serialization; the member names are SDK literals, never caller input. - /// - private void AppendBracketed(string name, string member, string value) + private void Append(string name, string value) { _builder ??= new StringBuilder(); _ = _builder.Append(_builder.Length is 0 ? '?' : '&') .Append(Uri.EscapeDataString(name)) - .Append('[') - .Append(member) - .Append(']') .Append('=') .Append(Uri.EscapeDataString(value)); } - private void Append(string name, string value) + /// + /// DeepObject keys ride the wire with literal brackets, matching the first-party + /// client's serialization; the member names are SDK literals, never caller input. + /// + private void AppendBracketed(string name, string member, string value) { _builder ??= new StringBuilder(); _ = _builder.Append(_builder.Length is 0 ? '?' : '&') .Append(Uri.EscapeDataString(name)) + .Append('[') + .Append(member) + .Append(']') .Append('=') .Append(Uri.EscapeDataString(value)); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index ba5ece81..c3cb3521 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -221,40 +221,6 @@ private static void ValidatePropertyOverrides(SpecDocument document, ReachableSc /// a dereferenced source goes dormant, and any structural divergence — the tag included — /// breaks the identity check. Every upstream move on the duplicate is loud. /// - /// - /// The mutual-exclusion relation lives only in spec prose, so a curated row carries it; - /// pairs only — larger groups wait for an operation that needs one. - /// - private static void ValidateMutuallyExclusiveQueries(HashSet selectedIds, - HashSet documentIds, GenerationCuration curation, BindingErrorCollector errors) - { - foreach (var row in curation.MutuallyExclusiveQueries) - { - if (string.IsNullOrWhiteSpace(row.Reason)) - { - errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must state a reason"); - } - - if (!documentIds.Contains(row.Operation)) - { - errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation absent from the spec"); - continue; - } - - if (!selectedIds.Contains(row.Operation)) - { - errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation outside the selected profile"); - } - - if (row.Parameters.Count is not 2 - || row.Parameters.Distinct(StringComparer.Ordinal).Count() is not 2 - || row.Parameters.Any(string.IsNullOrWhiteSpace)) - { - errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must name exactly two distinct parameters"); - } - } - } - private static void ValidateSchemaAliases(SpecDocument document, ReachableSchemaSet reachable, GenerationCuration curation, BindingErrorCollector errors) { @@ -310,5 +276,39 @@ private static void ValidateSchemaAliases(SpecDocument document, ReachableSchema } } + /// + /// The mutual-exclusion relation lives only in spec prose, so a curated row carries it; + /// pairs only — larger groups wait for an operation that needs one. + /// + private static void ValidateMutuallyExclusiveQueries(HashSet selectedIds, + HashSet documentIds, GenerationCuration curation, BindingErrorCollector errors) + { + foreach (var row in curation.MutuallyExclusiveQueries) + { + if (string.IsNullOrWhiteSpace(row.Reason)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must state a reason"); + } + + if (!documentIds.Contains(row.Operation)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation absent from the spec"); + continue; + } + + if (!selectedIds.Contains(row.Operation)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query row names an operation outside the selected profile"); + } + + if (row.Parameters.Count is not 2 + || row.Parameters.Distinct(StringComparer.Ordinal).Count() is not 2 + || row.Parameters.Any(string.IsNullOrWhiteSpace)) + { + errors.Add(BindingErrorCategory.Curation, row.Operation, "mutually-exclusive query rows must name exactly two distinct parameters"); + } + } + } + private static string GetGroup(SpecOperation operation) => operation.Segments[0]; } From c521105f2dcaaae87166d933eced192e34232cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sun, 16 Aug 2026 12:52:51 +0300 Subject: [PATCH 13/13] fix(tools): refuse a curated mutually-exclusive row on an operation with no query surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row wall lived behind the query-request binder, which returns early when an operation declares no query parameters — so a row naming such an operation went dormant instead of refusing, unlike the property-override and schema-alias drift contracts. The wall now answers for that case too, which is the shape a spec refresh would produce by dropping an operation's query parameters. --- .../Binding/OperationPlanBinderTests.cs | 16 ++++++++++++++++ .../Generator/Binding/OperationPlanBinder.cs | 3 +++ 2 files changed, 19 insertions(+) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index c9b66d21..716bd7f0 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -833,6 +833,22 @@ public async Task Bind_Should_Record_A_Curated_Mutually_Exclusive_Query_Pair() await Assert.That(pair.SecondWireName).IsEqualTo("cursor"); } + [Test] + public async Task Bind_Should_Refuse_A_Mutually_Exclusive_Row_When_The_Operation_Binds_No_Query_Surface() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(static _ => { })); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.list"), + Curation( + Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), + mutuallyExclusiveQueries: [ExclusiveQuery("v2.widget.list", "order", "cursor")]))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("does not carry query parameter", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Refuse_A_Mutually_Exclusive_Row_Naming_An_Absent_Parameter() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 9d7e0f4b..a7ddabb2 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -877,6 +877,9 @@ private int TemplatePosition(string wireName) .ToArray(); if (query.Length is 0) { + // An operation with no query surface still answers for its curated rows; + // otherwise a row naming it would go dormant instead of refusing. + _ = BindExclusivePairs([]); return null; }