From 4807bc8e726440bff0a275e0c4ad74dad97002b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 01:11:02 +0300 Subject: [PATCH 01/47] docs(plan): open M2 with the first breadth batch --- docs/ROADMAP.md | 6 +- .../handover-prompts/HANDOFF-2026-08-13-3.md | 75 ------------- docs/research/00-research-log.md | 51 +++++++++ .../2026-08-14-m2-first-breadth-batch.md | 101 ++++++++++++++++++ 4 files changed, 156 insertions(+), 77 deletions(-) delete mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md create mode 100644 docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9220ea5ef..2906e2edc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Date: 2026-08-13 +Date: 2026-08-14 Operational state: what is done, what is next, what is open. This file shrinks as work lands. Evergreen rules and locked decisions live in `../AGENTS.md`; decision records in `adr/`. @@ -27,7 +27,9 @@ the blocker set (typed-spine leaks, silent route rewriting) lands on the PR bran issues #19–#25 pinned to the milestone that resolves it — nothing on that list outlives the M series. -The next work is M2 — the first breadth batch, planned when it starts. +M2 is open: the first breadth batch (`session.list` + `session.get` + `session.create` + +`message.list`) executes per `superpowers/plans/2026-08-14-m2-first-breadth-batch.md`, +with its API decisions sealed (research log Session 19). ## Milestones diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md b/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md deleted file mode 100644 index a15624bf0..000000000 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-13-3.md +++ /dev/null @@ -1,75 +0,0 @@ -# Post-M1 Handoff — Ship The Branch, Then Open M2 - -Date: 2026-08-13 - -Consume this document against live Git and delete it when the branch merges and the M2 batch -plan exists. It records the queue between M1 (complete) and M2 (not yet planned). - -## Onboarding Reading Order - -1. `AGENTS.md`, then ADRs 0003/0004/0005/0007/0008/0009. -2. `docs/ROADMAP.md` — M1 is complete; the status paragraph is the current-state summary. -3. Research log Sessions 17–18 — the retarget chain and the Arc B execution decisions - (mechanical payload names, User-Agent policy, route shape). -4. `tests/OpenCode.Sdk.Sandbox/README.md` — the committed debug playground. - -## Git And Workspace State - -| Item | State | -|---|---| -| Branch | `feature/m1-callable-client`, ~26 commits ahead of `origin/master`, never pushed | -| Gates | All six green at every commit; pack fails only on `.generation-incomplete` | -| Tests | 526 across tools + SDK legs (net8/9/10 locally; net472 only on Windows CI) | -| Demo evidence | `.scratchpad/m1-demo/DEMO.md` (gitignored) — paste into the PR body | - -Gate chains need `set -o pipefail` plus an explicit ALL-GREEN marker — a bare `| tail` -swallows a red gate's exit code. - -## Step 1 — Ship the branch - -1. **Multi-agent review (maintainer-triggered):** the maintainer runs `/code-review ultra` - on this branch — it is user-invoked and billed, an agent cannot start it; the no-arg form - bundles the local branch and needs no remote. Triage findings red-test-first; anything - touching public API names goes back to the maintainer before changing the baseline. -2. **Push + PR to `master`** (push only on the maintainer's explicit ask). PR body: the M1 - story, the `DEMO.md` transcript, and the pin-vs-installed version skew note - (`a6a712a` vs `opencode2` v0.0.0-next-17403 — deliberate snapshot policy). -3. **Watch first-contact CI.** This work has never seen the three-OS matrix. Known risk: the - Windows-only net472 test leg renders the shared `PublicApi.verified.txt` from embedded - (Polyfill-supplied) attributes — if it diverges, split the baseline per TFM or scrub the - attribute rendering; do not weaken the baseline test. `App.net472.config` must never flow - into the package when packaging is later enabled. - -## Step 2 — Open M2 (first breadth batch) - -Write the 1–2 page batch plan when the branch merges, not earlier (ROADMAP rule). The -standing proposal to put before the maintainer: **`v2.session.list` + `v2.message.list` + -`v2.session.get`** — it converts the demo's raw curl calls into SDK methods and pulls in the -real M2 machinery: - -- Query-parameter binding (drops the M1 `no query` wall) including optional parameters - (`limit`, `order`, `cursor`) and the deferred `location[...]` scoping decision. -- List payloads (`IReadOnlyList`) and the `{cursor, data}` / `{data, hasMore}` envelopes; - the cursor/paginator shape from the API design spec is a **public API review**. -- The `List*` verb rule joining `OperationNamePolicy` (collection GETs currently name as - `Get*`). -- Consider a deliberate pin refresh at this boundary (M2 boundary is the sanctioned moment; - drift lands as reviewable git diffs). - -## Step 3 — Horizon (order revisited at each milestone boundary) - -- **M3 streams:** SSE engine; `v2.session.log` (`after`/`follow`) semantics re-researched. -- **M4 launcher:** `OpenCodeServer.StartAsync` over `opencode2 serve`; discovery-file and - `--stdio` leash are candidate mechanisms. -- **M5 full surface:** complete profile, exclusion fingerprints (ADR-0008), packaging - unblocks. -- **M6 operational closure:** `refresh-spec`, Extensions DI (the sandbox grows its Generic - Host root then), retry/telemetry/hooks. -- Standing chores: the manual analyzer-package bump routine (nothing warns when it lags); - v2 GA watch (research doc 15's open items). - -## Stop Conditions - -Public-API taste decisions go to the maintainer as options with a recommendation. The Arc B -stop-condition list (binder needing raw OpenAPI, name-branching emitters, structural unions, -ownership ambiguity, TFM breakage, partial packs) carries into M2 unchanged. diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index b11e84a5c..3d09d314c 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1657,3 +1657,54 @@ is arbitrated for the one generated file. Route member names ride the plan **Documentation pass (this session):** ROADMAP moved to M1-complete/M2-next; the Arc B plan's checkboxes closed in the same commits as their code; the consumed handover was deleted and a fresh one (`HANDOFF-2026-08-13-3.md`) records the ship/M2/horizon queue. + +# Session 19 — 2026-08-14: M1 ship, review triage, M2 opening decisions + +PR #16 merged after a verified multi-agent review cycle (blocker set fixed red-test-first +on the branch; every surviving finding milestone-anchored in issues #17–#25) and the +BenchmarkDotNet performance suite landed with baselines. This entry records the decisions +sealed while opening M2; execution detail lives in git and the M2 plan. + +## Q76: What shape does the generated query surface take? + +**How researched:** the four M2 operations extracted from the pinned spec (session.list +carries 9 optional `anyOf [T, null]` query parameters; both list operations share the +`limit`/`order`/`cursor` trio and the `{data, cursor:{previous,next}}` envelope); Azure SDK +.NET design guidelines fetched live ("DO use the options parameter pattern for complex +service methods"; `Pageable`/`AsyncPageable` for lists); Stripe.net +(`SessionListOptions : ListOptions` shared base) and OpenAI .NET (`*CollectionOptions`) +as ecosystem precedent. + +**Decision (maintainer, sealed):** per-operation generated options records deriving from a +shared `ListOptions` base that carries only the cursor-pagination trio; a fail-closed +generator profile-detection wall derives an operation from the base only on an exact wire +match (otherwise flat options); the base is the typed seam the M3 paginator will consume. +`*Options` = call shaping, `*Request` = wire body. + +## Q77: How is the string-typed `limit` exposed? + +**Found:** the wire types `limit` as a string; `uint` was weighed and rejected — FDG bans +unsigned types in public APIs (CLS compliance), the ecosystem has zero precedent +(`Take(int)`, Stripe `long?`, OpenAI `int`), and it does not even buy the invariant +(zero stays representable, so a guard is needed either way). + +**Decision (maintainer, sealed):** public `int?`, invariant-culture conversion at the +route boundary, non-positive values refused with `ArgumentException`. + +## Q78: How does the API express parentID's root-only sentinel? + +**Found:** the wire has three states — omitted (all sessions), `parentID=` (children), +and the literal string `"null"` (root sessions only). The schema shape is +self-identifying: `anyOf` of a `^ses`-patterned string and a single-value `"null"` enum. + +**Decision (maintainer, sealed):** a hand-written public spine type +`SessionParentFilter` (`RootOnly` singleton, `Of(id)` factory) carried by the options +record; the binder recognizes the wire shape mechanically, never the parameter name +(ADR-0008 stays intact); invalid combinations are unrepresentable at compile time. + +## Q79: What shape does the first request body take? + +**Decision (maintainer, sealed):** `session.create`'s inline all-optional body binds into +a generated `SessionCreateRequest`; the operation parameter is optional — +`CreateSessionAsync()` sends an empty JSON body. The `{Subject}{Verb}Request` pattern is +the mechanical rule for future bodies. diff --git a/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md new file mode 100644 index 000000000..10a58b1c0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md @@ -0,0 +1,101 @@ +# M2 First Breadth Batch Execution Plan + +Date: 2026-08-14 + +**Goal:** make `v2.session.list`, `v2.session.get`, `v2.session.create`, and +`v2.message.list` callable through their final generated SDK surface. This batch +deliberately opens the three machines M1 walled off: query-parameter binding, request +bodies (the first non-GET verb), and list envelopes with cursor pagination. + +**Architecture:** unchanged from Arc B — SpecIR plus curation bind into plans, generic +name-blind emitters render them, generated methods delegate once into the hand-written +`Pipeline`. The `Pipeline` grows exactly one capability: sending a JSON request body +serialized through the source-generated context. + +## What the pinned spec says (recon, 2026-08-14) + +- `session.list` — GET, **9 optional query parameters**, all `anyOf [T, null]`: + `limit` (string-typed in the spec), `order` (`asc`/`desc` enum), `search`, `cursor` + (opaque), `parentID` (with a literal `"null"` string variant meaning root-only), and the + location-scoping family `workspace`/`directory`/`project`/`subpath` (flat params — the + deferred `location[...]` question resolves to these). 400 is a **multi-tag anyOf** + (`InvalidCursorError` | `InvalidRequestError1` | `InvalidRequestError`). +- `session.create` — POST, inline request-body object (all-optional nullable properties: + `id`, `title`, `agent`, …), responds `{data: Session.Info}`. +- `session.get` — GET path param, `{data: Session.Info}`, 404 `SessionNotFoundError`. +- `message.list` — GET path + `limit`/`order`/`cursor`, responds the same envelope shape, + and declares **500 `UnknownError`** — the first 5xx in a status map. +- Both list responses share one envelope: `{data: [...], cursor: {previous?, next?}}` — + one mechanical list-envelope shape covers both. +- New reachable closure: the `Session.Info` graph (13 properties, nested + time/tokens/location/revert) plus `InvalidCursorError`, `UnknownError`, and the + dotted-dup name `InvalidRequestError1` (naming resolution must stay mechanical or fail + closed into a curated override). + +## Boundaries + +- Arc B boundaries carry over verbatim: opencode-dialect-only, fail-closed on any + unsupported selected shape, name-blind emitters, curation-driven public API, mechanical + payload names with collision-only overrides, optionality/nullability independence, + non-2xx stays on the API-error channel (now including 500), undeclared 2xx is a + protocol failure. +- The Arc B stop-condition list carries unchanged: binder needing raw OpenAPI, + name-branching emitters, structural unions, ownership ambiguity, TFM breakage, partial + packs. +- **Spec pin stays at `a6a712a`** for this batch — the pin is one day old; refreshing + buys nothing but churn. Next sanctioned refresh moment: M3 boundary. +- Cursor **pagination surface stays raw** in this batch: response envelopes expose the + wire cursor (`Previous`/`Next`), request options accept an opaque `cursor` string. A + paginator/`IAsyncEnumerable` convenience is an M3 candidate, designed together with the + stream surface — not here. + +## Public-API decisions (maintainer, sealed 2026-08-14) + +1. **Query surface:** generated per-operation options records deriving from one shared + `ListOptions` base that carries only the cursor-pagination trio (`Limit`, `Order`, + `Cursor`). The generator owns a fail-closed **profile-detection wall**: an operation + derives from the base only when its wire parameters match the profile exactly; + otherwise it gets flat standalone options. The base is the typed seam the M3 paginator + consumes. Dichotomy: `*Options` shapes the call (query), `*Request` is the wire body. +2. **`limit` is `int?`** — invariant conversion to the wire string at the route boundary; + non-positive values refused with `ArgumentException`. `uint` rejected: FDG bans + unsigned in public APIs (CLS), zero ecosystem precedent, and it does not buy the + invariant (0 stays representable). +3. **`parentID` rides `SessionParentFilter`** — a small hand-written public spine type + (`RootOnly` singleton, `Of(id)` factory); the binder recognizes the wire shape + mechanically (`anyOf` of a patterned string and the literal `"null"` enum), never the + parameter name. Invalid states are unrepresentable; no magic strings. +4. **Create body model is `SessionCreateRequest`** with an optional parameter — + `CreateSessionAsync()` sends an empty body (every property is optional on the wire). +5. **`List*`/`Create*` verb rules** join `OperationNamePolicy`, including the C18 + structural-verb-position fix (#22). + +## Tasks + +- [x] **0. Decision batch** — the five decisions above, sealed with the maintainer + 2026-08-14 (research log Session 19). +- [ ] **1. Curation + binding** — curation rows for the four operations; binder support + for optional nullable query parameters (fail-closed on any other query shape) and for + inline JSON request bodies bound into a generated request model; multi-tag 400 and the + 500 arm enter the status-map machinery. +- [ ] **2. Model closure** — regenerate with the `Session.Info` graph, the new error + types, and the list envelopes; resolve `InvalidRequestError1` naming mechanically or + fail closed into a curated override; API baseline review. +- [ ] **3. Pipeline body path** — `ExecuteAsync` overload (or body-carrying request + shape) serializing through `OpenCodeJsonContext`; `Content-Type: application/json`; + covered by pipeline tests (body bytes, header, GET stays body-less). +- [ ] **4. Emitters** — operation methods carrying options records and body models; route + builders composing escaped query strings; list-envelope response adapters; the + generator fail-closed walls batch (#21) lands here, where breadth first stresses those + walls, with P2's single-pass `{data}` DTO folded into the envelope design. +- [ ] **5. Naming batch (#22)** — `List*`/`Create*` rules + C18 fix; verify C17/C19/C20 + against the wall and fix what holds. +- [ ] **6. Hardening riders** — F07 concrete `Unknown*` converter (#19); decisions #20 + (password semantics) and #25 (IVT) to the maintainer alongside task 0. +- [ ] **7. Contract matrix + perf** — contract tests over the four operations (success, + every declared error status, cursor round-trip, undeclared-2xx, empty-marker); extend + `ClientOperationBenchmarks` with a `ListMessagesAsync` benchmark once the surface + exists. +- [ ] **8. Close-out** — live sandbox demo (create → list → get → messages) against + `opencode2 serve`; ROADMAP status update; research-log entries for decisions sealed + during execution. From b5be6334dcd31f6f4e83acb09c5f56b51cde8584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 01:15:07 +0300 Subject: [PATCH 02/47] docs(agents): hand over the M2 execution state --- .../handover-prompts/HANDOFF-2026-08-14-1.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md b/docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md new file mode 100644 index 000000000..b4a9e3746 --- /dev/null +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md @@ -0,0 +1,78 @@ +# M2 Execution Handoff — Task 1 Onward + +Date: 2026-08-14 + +Consume this document against live Git and delete it when the M2 batch branch merges. +It records the state between the M2 opening (plan sealed, committed) and the batch's +execution. The operative document is the plan — this handoff only carries what the plan +and the evergreen docs do not. + +## Onboarding Reading Order + +1. `AGENTS.md`, then ADRs 0003/0004/0005/0007/0008/0009. +2. `docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md` — **the operative + document**: sealed API decisions, boundaries, task list (Task 0 done, Tasks 1–8 open). +3. Research log Session 19 (Q76–Q79 — the sealed M2 decisions with their evidence); + Sessions 17–18 for the v2-retarget and Arc B context. +4. `docs/ROADMAP.md` — status, milestone anchors, the #19–#25 issue queue. + +## Git And Workspace State + +| Item | State | +|---|---| +| `master` | `75af30e` — PR #16 merged: M1 callable client + review blocker fixes + performance suite | +| Branch | `feature/m2-first-breadth-batch` @ `4807bc8` (plan-only commit, pushed) | +| Tree | clean; all six gates green; 571 tests across tools + SDK legs | +| CI | three-OS matrix green on master and on the merged PR | +| Issues | #17 (blockers) and #18 (perf infra) closed by the merge; #19–#25 open, milestone-anchored | +| Perf baseline | issue #18 comment @ `959c4f0`: end-to-end `GetMessageAsync` 67.4 μs / 27.42 KB; union walk alone 52.8 μs / 17.37 KB (~78% of total); `GetHealthAsync` 1.56 μs / 2.08 KB | + +## Next Work + +Plan Task 1: curation rows for the four operations; binder support for optional +nullable query parameters (with the `ListOptions` profile-detection wall) and inline +request-body binding. Then Tasks 2–8 in plan order. The sealed decisions (options/base +shape, `int?` limit, `SessionParentFilter`, `SessionCreateRequest`, `List*`/`Create*` +verb rules) are settled — do not reopen without new evidence. + +## Session Mechanics The Docs Don't Carry + +- **Dev loop:** the maintainer authorizes committing per green increment and pushing + within the loop; red-test-first is demanded; public-API taste decisions are batched to + the maintainer as options with a recommendation (they often counter-question, then + seal). Run all six gates before every commit, chained with `set -o pipefail` and an + explicit ALL-GREEN marker. Do not use `grep -c` inside the chain — it exits 1 on zero + matches and reads as a red gate. +- **Regen ritual:** edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` + → review the git diff of committed output → run tests. Emitter changes turn the three + Verify snapshot tests in `OpenCode.Sdk.Tools.Tests/Snapshots/` red; review the received + diff, then accept received→verified. `generate --verify` must be a byte-identical no-op + before commit. +- **Public API baseline** (`PublicApi.verified.txt`, TFM-shared) will change in M2 — new + public surface is expected; the baseline diff is itself review surface for the + maintainer, never auto-accepted silently. +- **Perf discipline:** perf-touching changes carry before/after BenchmarkDotNet numbers + (`tests/OpenCode.Sdk.Performance.Tests`, `MemoryDiagnoser`); allocation deltas are the + primary regression currency (deterministic), timing secondary. Benchmarks are not a CI + gate. Task 7 adds a `ListMessagesAsync` benchmark once the surface exists. +- **Multi-TFM traps already paid for** (do not rediscover): downlevel legs need + conditional ``/`` items (test csproj has + them since `52a5e3b`); ns2.0's BCL lacks `[NotNullWhen(false)]`, so generated + non-null-proving guards carry an explicit `x is null ||` disjunct (see the union marker + guard); SonarAnalyzer relaxes test rules only on test-framework references, so + benchmark-style projects arbitrate via csproj `NoWarn` + a single-line + `slopwatch-ignore: SW005` comment; the net472 test leg builds only on Windows CI but + compile-probes locally via `-p:TargetFrameworks=net472`. +- **External review pattern:** the maintainer runs reviews outside this harness from + hand-off instruction files (gitignored `.scratchpad/ultra-review-handoff.md` + + `VERIFY-HANDOFF.md` from the M1 cycle — reusable templates with the commit range and a + JSON result contract). Findings come back as scratchpad reports; triage verifies before + trusting, then everything surviving is preserved into GitHub issues (nothing lives only + in scratchpad). + +## Stop Conditions + +Arc B's list carries through the plan's boundaries (binder needing raw OpenAPI, +name-branching emitters, structural unions, ownership ambiguity, TFM breakage, partial +packs). Public-API taste goes to the maintainer. Maintainer decisions #20 (password +semantics) and #25 (IVT) are queued to be put before them alongside M2 execution. From dd2c0ea2982cd700e60a6f34bf56df7408cd6c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:04:44 +0300 Subject: [PATCH 03/47] feat(generator): bind optional nullable query parameters into options plans The binder opens the M1 query wall: optional nullable query parameters bind into per-operation options plans carrying wire names, PascalCase property names, and shape-derived value kinds (text, positive count, asc/desc order, parent-session filter). A fail-closed profile wall derives an operation from the ListOptions base only when its wire parameters are exactly the cursor-pagination trio; anything unrecognized (required, non-nullable, deep-object, foreign enums, object shapes) refuses generation. Query schemas stay out of the model closure, and options type names join the generated-type collision checks. --- .../Binding/OperationPlanBinderTests.cs | 206 +++++++++++++++++- .../Binding/Models/OperationOptionsPlan.cs | 20 ++ .../Generator/Binding/Models/OperationPlan.cs | 3 + .../Binding/Models/OptionsPropertyPlan.cs | 13 ++ .../Binding/Models/QueryValueKind.cs | 17 ++ .../Generator/Binding/OperationNamePolicy.cs | 33 +++ .../Generator/Binding/OperationPlanBinder.cs | 133 ++++++++++- .../Binding/ReachableSchemaCollector.cs | 5 +- 8 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationOptionsPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OptionsPropertyPlan.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 533fd91fe..b477cde89 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -187,16 +187,164 @@ public async Task Bind_Should_Refuse_A_Non_Get_Operation() } [Test] - public async Task Bind_Should_Refuse_A_Query_Parameter() + public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Into_Flat_Options() { - var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec - .WithSchema("ItemInfo", schema => schema.Type("object") - .Property("id", property => property.Type("string"), required: true)) - .WithOperation("v2.health.get", configure: operation => operation - .Parameter("limit", "query", schema => schema.Type("string")) - .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString) + .Parameter("search", "query", QueryScenarioData.NullableString) + .Parameter("parentID", "query", QueryScenarioData.NullableParentFilter))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Options).IsNotNull(); + await Assert.That(list.Options!.TypeName).IsEqualTo("WidgetListOptions"); + await Assert.That(list.Options.DerivesFromListOptions).IsFalse(); + await Assert.That(list.Options.Properties.Select(static property => property.WireName) + .SequenceEqual(["limit", "order", "cursor", "search", "parentID"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + .SequenceEqual(["Limit", "Order", "Cursor", "Search", "ParentId"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.Options.Properties.Select(static property => property.Kind) + .SequenceEqual([ + QueryValueKind.PositiveCount, + QueryValueKind.ListOrder, + QueryValueKind.Text, + QueryValueKind.Text, + QueryValueKind.SessionParentFilter, + ])).IsTrue(); + await Assert.That(list.Options.Properties.All(static property => !property.IsInherited)).IsTrue(); + await Assert.That(list.Parameters).IsEmpty(); + } + + [Test] + public async Task Bind_Should_Keep_Query_Parameter_Schemas_Out_Of_The_Model_Closure() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("parentID", "query", QueryScenarioData.NullableParentFilter))); + + var plan = BindWidgets(document); + + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Unions).IsEmpty(); + await Assert.That(plan.Registry.TypeNames + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Derive_Options_From_The_List_Options_Base_When_The_Trio_Matches_Exactly() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Options!.TypeName).IsEqualTo("WidgetListOptions"); + await Assert.That(list.Options.DerivesFromListOptions).IsTrue(); + await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + .SequenceEqual(["Limit", "Order", "Cursor"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.Options.Properties.All(static property => property.IsInherited)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Keep_Options_Flat_When_The_Trio_Has_Extra_Parameters() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString) + .Parameter("search", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Options!.DerivesFromListOptions).IsFalse(); + await Assert.That(list.Options.Properties.All(static property => !property.IsInherited)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Keep_Options_Flat_When_The_Trio_Is_Incomplete() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.Options!.DerivesFromListOptions).IsFalse(); + await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + .SequenceEqual(["Limit", "Cursor"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Leave_Options_Absent_When_An_Operation_Has_No_Query_Parameters() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario()); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part"), + Curation(Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID")))); + + var part = plan.Clients.Single(static client => client.Role == ClientRole.Handle).Operations.Single(); + await Assert.That(part.Options).IsNull(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Required_Query_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString, required: true))); + + await AssertWidgetRefusalAsync(document, "must be optional"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Query_Parameter_That_Does_Not_Admit_Null() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", schema => schema.Type("string")))); + + await AssertWidgetRefusalAsync(document, "must admit null"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Query_Enum_Outside_The_Order_Profile() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("order", "query", schema => schema.AnyOf( + branch => branch.Type("string").Enum("asc", "desc", "shuffled"), + branch => branch.Type("null"))))); + + await AssertWidgetRefusalAsync(document, "unsupported schema shape"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Object_Query_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("filter", "query", schema => schema.AnyOf( + branch => branch.Type("object") + .Property("name", property => property.Type("string"), required: true), + branch => branch.Type("null"))))); - await AssertOperationRefusalAsync(document, "v2.health.get", "required path parameter"); + await AssertWidgetRefusalAsync(document, "unsupported schema shape"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Deep_Object_Query_Parameter() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("location", "query", QueryScenarioData.NullableString, deepObject: true))); + + await AssertWidgetRefusalAsync(document, "deep-object"); } [Test] @@ -454,6 +602,48 @@ await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCa && error.Problem.Contains(expectedProblem, StringComparison.Ordinal))).IsTrue(); } + private static SpecScenario WidgetListScenario(Action configureParameters) => + SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => + { + configureParameters(operation); + _ = operation.Response(200, "application/json", schema => schema.Ref("WidgetInfo")); + })); + + private static EmitPlan BindWidgets(SpecDocument document) => + new BindingTestHost().Bind( + document, + Selection("v2.widget.list"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)))); + + private static async Task AssertWidgetRefusalAsync(SpecDocument document, string expectedProblem) + { + var exception = Assert.Throws(() => _ = BindWidgets(document)); + + await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCategory.Operation + && string.Equals(error.Subject, "v2.widget.list", StringComparison.Ordinal) + && error.Problem.Contains(expectedProblem, StringComparison.Ordinal))).IsTrue(); + } + + private static class QueryScenarioData + { + public static void NullableString(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null")); + + public static void NullableOrderEnum(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.Type("string").Enum("asc", "desc"), + static branch => branch.Type("null")); + + public static void NullableParentFilter(SchemaBuilder schema) => schema.AnyOf( + static branch => branch.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")); + } + private static SpecScenario GadgetScenario(Action? extend = null, bool parametersReversed = false) => SpecScenario.Define(spec => { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationOptionsPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationOptionsPlan.cs new file mode 100644 index 000000000..8b79c0440 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationOptionsPlan.cs @@ -0,0 +1,20 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record OperationOptionsPlan +{ + public required string TypeName { get; init; } + + /// Gets a value indicating whether the record derives from the ListOptions base. + public required bool DerivesFromListOptions { get; init; } + + /// Gets every bound query parameter in wire order, including base-inherited ones. + public required IReadOnlyList Properties + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs index 9c0acc388..0352f4e4b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs @@ -26,6 +26,9 @@ public required IReadOnlyList Parameters } } = Array.AsReadOnly(Array.Empty()); + /// Gets the query-options record plan, or when the operation has no query parameters. + public OperationOptionsPlan? Options { get; init; } + public required EnvelopePlan Envelope { get; init; } public required ErrorMapPlan ErrorMap { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OptionsPropertyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OptionsPropertyPlan.cs new file mode 100644 index 000000000..32da7c7b4 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OptionsPropertyPlan.cs @@ -0,0 +1,13 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record OptionsPropertyPlan +{ + public required string WireName { get; init; } + + public required string PropertyName { get; init; } + + public required QueryValueKind Kind { get; init; } + + /// Gets a value indicating whether the property is inherited from the ListOptions base. + public required bool IsInherited { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs new file mode 100644 index 000000000..0ba45fa96 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryValueKind.cs @@ -0,0 +1,17 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// Identifies how a bound query parameter value maps between C# and the wire. +internal enum QueryValueKind +{ + /// A plain string carried verbatim. + Text = 0, + + /// A positive count exposed as int? and written invariantly to the wire string. + PositiveCount = 1, + + /// The asc/desc order enum exposed as the shared ListOrder spine type. + ListOrder = 2, + + /// The parent-session filter whose wire admits an identifier or the literal "null". + SessionParentFilter = 3, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index 8614d3425..f66d087c0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -6,6 +6,29 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// Derives C# member and type names for one bound operation from its identifier segments. internal static class OperationNamePolicy { + /// + /// Identifier segments recognized as operation verbs when they close the identifier; + /// verb detection is structural (final position only), never value-filtering. + /// + private static readonly string[] KnownVerbSegments = ["create", "get", "list"]; + + /// Gets the operation verb: a recognized final identifier segment, or the HTTP method. + public static string Verb(SpecOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + return HasVerbSegment(operation) + ? CSharpNamePolicy.ToPascalCase(operation.Segments[^1]) + : CSharpNamePolicy.ToPascalCase(operation.Method); + } + + public static string OptionsTypeName(SpecOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{MiddleSubject(operation)}{Verb(operation)}Options"; + } + public static string MethodName(SpecOperation operation, GroupPlacement placement) { ArgumentNullException.ThrowIfNull(operation); @@ -47,4 +70,14 @@ private static string Subject(SpecOperation operation) => .Skip(1) .Where(segment => !string.Equals(segment, operation.Method, StringComparison.Ordinal)) .Select(CSharpNamePolicy.ToPascalCase)); + + /// The segments between the group and the closing verb segment, when one exists. + private static string MiddleSubject(SpecOperation operation) + { + var count = operation.Segments.Count - (HasVerbSegment(operation) ? 2 : 1); + return string.Concat(operation.Segments.Skip(1).Take(count).Select(CSharpNamePolicy.ToPascalCase)); + } + + private static bool HasVerbSegment(SpecOperation operation) => + operation.Segments.Count > 1 && KnownVerbSegments.Contains(operation.Segments[^1], StringComparer.Ordinal); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 3a946b982..b955d41b1 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -172,6 +172,15 @@ private void CheckTypeNameCollisions(List clients, List + operation.Plan.Options is not null && !owners.Add(operation.Plan.Options.TypeName))) + { + errors.Add( + BindingErrorCategory.Naming, + operation.OperationId, + $"options type name '{operation.Plan.Options!.TypeName}' collides with another generated type"); + } + var routeMembers = new HashSet(_comparer); foreach (var operation in bound.Where(operation => !routeMembers.Add($"{operation.Plan.RouteContainerName}.{operation.Plan.RouteMemberName}"))) @@ -224,7 +233,9 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation var envelope = BindEnvelope(success); var errorMap = BindErrorMap(); var parameters = BindParameters(row); - if (envelope is null || errorMap is null || parameters is null) + var optionsErrorsBefore = _errors.Count; + var options = BindQueryOptions(); + if (envelope is null || errorMap is null || parameters is null || _errors.Count != optionsErrorsBefore) { return null; } @@ -243,6 +254,7 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation RouteContainerName = row.ClientName ?? CSharpNamePolicy.ToPascalCase(group), RouteMemberName = OperationNamePolicy.RouteMemberName(_operation), Parameters = parameters, + Options = options, Envelope = envelope, ErrorMap = errorMap, Summary = _operation.Summary, @@ -286,17 +298,17 @@ private bool CheckWireShape() private void CheckParameterShapes() { - foreach (var parameter in _operation.Parameters) + foreach (var parameter in _operation.Parameters.Where(static parameter => parameter.Location is SpecParameterLocation.Path)) { - if (parameter is not { Location: SpecParameterLocation.Path, IsRequired: true, IsDeepObject: false }) + if (parameter is not { IsRequired: true, IsDeepObject: false }) { - Refuse($"parameter '{parameter.Name}' must be a required path parameter"); + Refuse($"path parameter '{parameter.Name}' must be required and plainly encoded"); continue; } if (Resolve(parameter.Schema) is not PrimitiveNode { Kind: PrimitiveKind.String }) { - Refuse($"parameter '{parameter.Name}' must be a plain string"); + Refuse($"path parameter '{parameter.Name}' must be a plain string"); } } } @@ -494,6 +506,7 @@ or SpecEnvelopeShape.DataHasMore private IReadOnlyList? BindParameters(GroupCuration row) { var plans = _operation.Parameters + .Where(static parameter => parameter.Location is SpecParameterLocation.Path) .Select(parameter => new OperationParameterPlan { WireName = parameter.Name, @@ -524,6 +537,116 @@ private int TemplatePosition(string wireName) return position; } + private OperationOptionsPlan? BindQueryOptions() + { + var query = _operation.Parameters + .Where(static parameter => parameter.Location is SpecParameterLocation.Query) + .ToArray(); + if (query.Length is 0) + { + return null; + } + + var properties = new List(query.Length); + foreach (var parameter in query) + { + if (parameter.IsDeepObject) + { + Refuse($"query parameter '{parameter.Name}' must not use deep-object encoding"); + continue; + } + + if (parameter.IsRequired) + { + Refuse($"query parameter '{parameter.Name}' must be optional"); + continue; + } + + if (Resolve(parameter.Schema) is not NullableNode nullable) + { + Refuse($"query parameter '{parameter.Name}' must admit null"); + continue; + } + + var kind = ResolveQueryValueKind(parameter.Name, nullable.Inner); + if (kind is null) + { + Refuse($"query parameter '{parameter.Name}' has an unsupported schema shape"); + continue; + } + + properties.Add(new OptionsPropertyPlan + { + WireName = parameter.Name, + PropertyName = CSharpNamePolicy.ToPascalCase(parameter.Name), + Kind = kind.Value, + IsInherited = false, + }); + } + + var duplicate = properties.GroupBy(static property => property.PropertyName, StringComparer.Ordinal) + .FirstOrDefault(static property => property.Skip(1).Any()); + if (duplicate is not null) + { + _errors.Add( + BindingErrorCategory.Naming, + _operation.OperationId, + $"multiple query parameters map to C# name '{duplicate.Key}'"); + return null; + } + + if (MatchesListOptionsProfile(properties)) + { + properties = [.. properties.Select(static property => property with { IsInherited = true })]; + } + + return new OperationOptionsPlan + { + TypeName = OperationNamePolicy.OptionsTypeName(_operation), + DerivesFromListOptions = properties.Count > 0 && properties[0].IsInherited, + Properties = properties, + }; + } + + /// + /// The fail-closed profile wall: an operation derives from the ListOptions base + /// only when its wire query parameters are exactly the cursor-pagination trio. + /// + private static bool MatchesListOptionsProfile(List properties) => + properties.Count is 3 + && properties.Any(static property => property is { WireName: "limit", Kind: QueryValueKind.PositiveCount }) + && properties.Any(static property => property is { WireName: "order", Kind: QueryValueKind.ListOrder }) + && properties.Any(static property => property is { WireName: "cursor", Kind: QueryValueKind.Text }); + + private QueryValueKind? ResolveQueryValueKind(string wireName, SchemaNode inner) + { + return Resolve(inner) switch + { + PrimitiveNode { Kind: PrimitiveKind.String } => + string.Equals(wireName, "limit", StringComparison.Ordinal) ? QueryValueKind.PositiveCount : QueryValueKind.Text, + EnumNode { Values: ["asc", "desc"] } => QueryValueKind.ListOrder, + UnionNode { Classification: UnionClassification.Structural, Branches: [var first, var second] } + when IsParentFilterShape(first, second) => QueryValueKind.SessionParentFilter, + _ => null, + }; + } + + /// + /// Recognizes the parent-filter wire shape — a patterned identifier string beside the + /// literal "null" — structurally, never by parameter name. + /// + private bool IsParentFilterShape(SchemaNode first, SchemaNode second) + { + var left = Resolve(first); + var right = Resolve(second); + return (IsIdentifierString(left) && IsNullLiteral(right)) + || (IsIdentifierString(right) && IsNullLiteral(left)); + } + + private static bool IsIdentifierString(SchemaNode schema) => schema is PrimitiveNode { Kind: PrimitiveKind.String }; + + private static bool IsNullLiteral(SchemaNode schema) => schema is LiteralNode { Kind: LiteralKind.String, Value: "null" }; + private void Refuse(string problem) => _errors.Add(BindingErrorCategory.Operation, _operation.OperationId, problem); private string? RefuseNull(string problem) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs index ae97232d9..46623867c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs @@ -16,7 +16,10 @@ public ReachableSchemaSet Collect(SpecDocument document, IReadOnlyList parameter.Location is SpecParameterLocation.Path)) { traversal.Visit(parameter.Schema); } From 2b56b75a777c9eafec8949417462e222ea26e8b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:10:05 +0300 Subject: [PATCH 04/47] feat(generator): bind JSON request bodies into generated request models POST joins the admitted methods, paired fail-closed with bodies: GET must not carry one, POST must. A required JSON body referencing an object schema binds into a request-body plan; the operation parameter is optional exactly when every body property is optional, so an empty call sends an empty JSON object. Inline body schemas are named from operation identity through the mechanical {Subject}{Verb}Request rule instead of their op-rooted graph keys, which would otherwise surface the protocol prefix ADR-0005 strips. --- .../Binding/OperationPlanBinderTests.cs | 104 ++++++++++++++++-- .../Generator/Binding/Models/OperationPlan.cs | 3 + .../Binding/Models/RequestBodyPlan.cs | 14 +++ .../Generator/Binding/OperationNamePolicy.cs | 7 ++ .../Generator/Binding/OperationPlanBinder.cs | 61 +++++++++- .../Generator/Binding/SchemaNameResolver.cs | 31 +++++- 6 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RequestBodyPlan.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index b477cde89..c998a69ff 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -175,7 +175,19 @@ await Assert.That(part.Parameters.Select(static parameter => parameter.WireName) } [Test] - public async Task Bind_Should_Refuse_A_Non_Get_Operation() + public async Task Bind_Should_Refuse_An_Unsupported_Http_Method() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.health.reset", method: "put", path: "/api/health-reset", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + await AssertOperationRefusalAsync(document, "v2.health.reset", "HTTP method"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Post_Without_A_Request_Body() { var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema.Type("object") @@ -183,7 +195,71 @@ public async Task Bind_Should_Refuse_A_Non_Get_Operation() .WithOperation("v2.health.reset", method: "post", path: "/api/health-reset", configure: operation => operation .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); - await AssertOperationRefusalAsync(document, "v2.health.reset", "only GET operations"); + await AssertOperationRefusalAsync(document, "v2.health.reset", "must carry a request body"); + } + + [Test] + public async Task Bind_Should_Bind_A_Json_Request_Body_Into_A_Request_Model() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario(body => body.Type("object") + .AdditionalPropertiesFalse() + .Property("id", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))) + .Property("title", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))))); + + var plan = BindWidgets(document, "v2.widget.create"); + + var create = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(create.HttpMethod).IsEqualTo("post"); + await Assert.That(create.RequestBody).IsNotNull(); + await Assert.That(create.RequestBody!.TypeName).IsEqualTo("WidgetCreateRequest"); + await Assert.That(create.RequestBody.ParameterName).IsEqualTo("request"); + await Assert.That(create.RequestBody.IsOptional).IsTrue(); + await Assert.That(plan.Models.Select(static model => model.Name)).Contains("WidgetCreateRequest"); + await Assert.That(plan.Registry.TypeNames).Contains("WidgetCreateRequest"); + } + + [Test] + public async Task Bind_Should_Require_The_Request_Parameter_When_The_Body_Has_Required_Properties() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario(body => body.Type("object") + .Property("title", property => property.Type("string"), required: true))); + + 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!.IsOptional).IsFalse(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Non_Json_Request_Body() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario( + body => body.Type("object").Property("title", property => property.Type("string")), + mediaType: "text/plain")); + + await AssertWidgetRefusalAsync(document, "JSON", "v2.widget.create"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Non_Object_Request_Body() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario(body => body.Type("string"))); + + await AssertWidgetRefusalAsync(document, "object schema", "v2.widget.create"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Optional_Request_Body() + { + var document = await BindingTestHost.IngestAsync(WidgetCreateScenario( + body => body.Type("object").Property("title", property => property.Type("string")), + required: false)); + + await AssertWidgetRefusalAsync(document, "declared required", "v2.widget.create"); } [Test] @@ -348,7 +424,7 @@ public async Task Bind_Should_Refuse_A_Deep_Object_Query_Parameter() } [Test] - public async Task Bind_Should_Refuse_A_Request_Body() + public async Task Bind_Should_Refuse_A_Request_Body_On_A_Get_Operation() { var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema.Type("object") @@ -358,7 +434,7 @@ public async Task Bind_Should_Refuse_A_Request_Body() .Property("value", property => property.Type("string"), required: true)) .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); - await AssertOperationRefusalAsync(document, "v2.health.get", "request bodies"); + await AssertOperationRefusalAsync(document, "v2.health.get", "must not carry a request body"); } [Test] @@ -612,18 +688,28 @@ private static SpecScenario WidgetListScenario(Action configur _ = operation.Response(200, "application/json", schema => schema.Ref("WidgetInfo")); })); - private static EmitPlan BindWidgets(SpecDocument document) => + private static SpecScenario WidgetCreateScenario(Action configureBody, + string mediaType = "application/json", bool required = true) => + 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 + .RequestBody(mediaType, configureBody, required) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo")))); + + private static EmitPlan BindWidgets(SpecDocument document, string operationId = "v2.widget.list") => new BindingTestHost().Bind( document, - Selection("v2.widget.list"), + Selection(operationId), Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)))); - private static async Task AssertWidgetRefusalAsync(SpecDocument document, string expectedProblem) + private static async Task AssertWidgetRefusalAsync(SpecDocument document, string expectedProblem, + string operationId = "v2.widget.list") { - var exception = Assert.Throws(() => _ = BindWidgets(document)); + var exception = Assert.Throws(() => _ = BindWidgets(document, operationId)); await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCategory.Operation - && string.Equals(error.Subject, "v2.widget.list", StringComparison.Ordinal) + && string.Equals(error.Subject, operationId, StringComparison.Ordinal) && error.Problem.Contains(expectedProblem, StringComparison.Ordinal))).IsTrue(); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs index 0352f4e4b..16b3f60f6 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs @@ -29,6 +29,9 @@ public required IReadOnlyList Parameters /// Gets the query-options record plan, or when the operation has no query parameters. public OperationOptionsPlan? Options { get; init; } + /// Gets the request-body plan, or when the operation sends no body. + public RequestBodyPlan? RequestBody { get; init; } + public required EnvelopePlan Envelope { get; init; } public required ErrorMapPlan ErrorMap { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RequestBodyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RequestBodyPlan.cs new file mode 100644 index 000000000..11b512ab6 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RequestBodyPlan.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +internal sealed record RequestBodyPlan +{ + public required string TypeName { get; init; } + + public required string ParameterName { get; init; } + + /// + /// Gets a value indicating whether the operation parameter is optional; a body whose + /// properties are all optional sends an empty JSON object when the caller passes nothing. + /// + public required bool IsOptional { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index f66d087c0..4e4efe861 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -29,6 +29,13 @@ public static string OptionsTypeName(SpecOperation operation) return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{MiddleSubject(operation)}{Verb(operation)}Options"; } + public static string RequestTypeName(SpecOperation operation) + { + ArgumentNullException.ThrowIfNull(operation); + + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{MiddleSubject(operation)}{Verb(operation)}Request"; + } + public static string MethodName(SpecOperation operation, GroupPlacement placement) { ArgumentNullException.ThrowIfNull(operation); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index b955d41b1..4cc9a4cbb 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -233,9 +233,10 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation var envelope = BindEnvelope(success); var errorMap = BindErrorMap(); var parameters = BindParameters(row); - var optionsErrorsBefore = _errors.Count; + var optionalPlanErrorsBefore = _errors.Count; var options = BindQueryOptions(); - if (envelope is null || errorMap is null || parameters is null || _errors.Count != optionsErrorsBefore) + var requestBody = BindRequestBody(); + if (envelope is null || errorMap is null || parameters is null || _errors.Count != optionalPlanErrorsBefore) { return null; } @@ -255,6 +256,7 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation RouteMemberName = OperationNamePolicy.RouteMemberName(_operation), Parameters = parameters, Options = options, + RequestBody = requestBody, Envelope = envelope, ErrorMap = errorMap, Summary = _operation.Summary, @@ -266,9 +268,11 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation private bool CheckWireShape() { var before = _errors.Count; - if (!string.Equals(_operation.Method, "get", StringComparison.Ordinal)) + var isGet = string.Equals(_operation.Method, "get", StringComparison.Ordinal); + var isPost = string.Equals(_operation.Method, "post", StringComparison.Ordinal); + if (!isGet && !isPost) { - Refuse("only GET operations are supported in M1"); + Refuse($"HTTP method '{_operation.Method}' is not supported"); } if (_operation.HasWildcardPath) @@ -286,9 +290,14 @@ private bool CheckWireShape() Refuse("event-stream responses are not supported in M1"); } - if (_operation.RequestBody is not null) + if (isGet && _operation.RequestBody is not null) { - Refuse("request bodies are not supported in M1"); + Refuse("GET operations must not carry a request body"); + } + + if (isPost && _operation.RequestBody is null) + { + Refuse("POST operations must carry a request body"); } CheckParameterShapes(); @@ -608,6 +617,46 @@ private int TemplatePosition(string wireName) }; } + private RequestBodyPlan? BindRequestBody() + { + var body = _operation.RequestBody; + if (body is null) + { + return null; + } + + if (body.ContentType is not { IsJson: true }) + { + Refuse("the request body must carry a JSON schema"); + return null; + } + + if (!body.IsRequired) + { + Refuse("the request body must be declared required"); + return null; + } + + if (body.Schema is not RefNode reference || Resolve(body.Schema) is not ObjectNode target) + { + Refuse("the request body must reference an object schema"); + return null; + } + + if (!_typeNames.TryGetValue(reference.Target, out var typeName)) + { + _errors.Add(BindingErrorCategory.Naming, reference.Target, "request body schema has no unique C# type name"); + return null; + } + + return new RequestBodyPlan + { + TypeName = typeName, + ParameterName = "request", + IsOptional = target.Properties.All(static property => !property.IsRequired), + }; + } + /// /// The fail-closed profile wall: an operation derives from the ListOptions base /// only when its wire query parameters are exactly the cursor-pagination trio. diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs index 88d64456d..1bfcccdf0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs @@ -15,6 +15,7 @@ public IReadOnlyDictionary Resolve(SpecDocument document, Reacha ArgumentNullException.ThrowIfNull(errors); var responseRoots = reachable.ResponseRootKeys.ToHashSet(_comparer); + var requestRoots = ResolveRequestBodyRootNames(document); var result = new Dictionary(_comparer); var owners = new Dictionary(_comparer); foreach (var key in reachable.GraphKeys) @@ -24,7 +25,15 @@ public IReadOnlyDictionary Resolve(SpecDocument document, Reacha continue; } - var name = schema is UnionNode union ? ResolveUnionName(key, union, document.Schemas) : ResolveDefault(key); + string name; + if (requestRoots.TryGetValue(key, out var requestName)) + { + name = requestName; + } + else + { + name = schema is UnionNode union ? ResolveUnionName(key, union, document.Schemas) : ResolveDefault(key); + } if (owners.TryGetValue(name, out var existing)) { errors.Add(BindingErrorCategory.Naming, key, $"C# type name '{name}' collides with schema '{existing}'"); @@ -40,6 +49,26 @@ public IReadOnlyDictionary Resolve(SpecDocument document, Reacha private static bool IsNominal(SchemaNode schema) => schema is ObjectNode or EnumNode or UnionNode; + /// + /// Inline request bodies are named from their operation identity — the mechanical + /// {Subject}{Verb}Request rule — because their graph keys carry the raw + /// operation id, which public names never surface (ADR-0005 strips the protocol prefix). + /// + private Dictionary ResolveRequestBodyRootNames(SpecDocument document) + { + var names = new Dictionary(_comparer); + foreach (var operation in document.Operations) + { + if (operation.RequestBody?.Schema is RefNode reference + && reference.Target.StartsWith($"op:{operation.OperationId}#", StringComparison.Ordinal)) + { + names[reference.Target] = OperationNamePolicy.RequestTypeName(operation); + } + } + + return names; + } + private string ResolveUnionName(string key, UnionNode union, IReadOnlyDictionary graph) { if (!key.Contains('#', StringComparison.Ordinal)) From 2762329ab622469c47304fef244159a9abd2ab9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:15:31 +0300 Subject: [PATCH 05/47] feat(generator): bind cursor-list envelopes and model the envelope kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The envelope plan carries an explicit kind (bare, data, cursor list) instead of a data-envelope flag. Cursor-list responses bind fail-closed: the wrapper must require exactly 'data' and 'cursor', the cursor must be the optional-nullable previous/next object, and items must reference named component schemas. Wrapper roots — component-named ones included — and the cursor object stay out of the model closure: they are envelope structure the response spine renders, never generated models. --- .../Binding/OperationPlanBinderTests.cs | 89 ++++++++++++++++++- .../Support/EmitterPlanFixture.cs | 6 +- .../Generator/Binding/Models/EnvelopeKind.cs | 14 +++ .../Generator/Binding/Models/EnvelopePlan.cs | 4 +- .../Generator/Binding/OperationPlanBinder.cs | 62 +++++++++++-- .../Binding/ReachableSchemaCollector.cs | 40 ++++++++- .../Emission/ResponseAdapterEmitter.cs | 8 +- 7 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index c998a69ff..63248aa9e 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -36,7 +36,7 @@ await Assert.That(plan.Clients.Select(static client => client.Name) await Assert.That(health.Envelope.AdapterTypeName).IsEqualTo("HealthResponseAdapter"); await Assert.That(health.Envelope.PayloadName).IsEqualTo("Health"); await Assert.That(health.Envelope.PayloadTypeName).IsEqualTo("ServiceHealth"); - await Assert.That(health.Envelope.HasDataEnvelope).IsFalse(); + await Assert.That(health.Envelope.Kind).IsEqualTo(EnvelopeKind.Bare); await Assert.That(health.ErrorMap.Statuses.Select(static status => status.StatusCode) .SequenceEqual([400, 401])).IsTrue(); await Assert.That(health.ErrorMap.Statuses[0].Tags.Single().Tag).IsEqualTo("InvalidRequestError"); @@ -73,7 +73,7 @@ await Assert.That(message.Parameters.Select(static parameter => parameter.Name) await Assert.That(message.Envelope.AdapterTypeName).IsEqualTo("SessionMessageResponseAdapter"); await Assert.That(message.Envelope.PayloadName).IsEqualTo("Message"); await Assert.That(message.Envelope.PayloadTypeName).IsEqualTo("SessionMessageInfo"); - await Assert.That(message.Envelope.HasDataEnvelope).IsTrue(); + await Assert.That(message.Envelope.Kind).IsEqualTo(EnvelopeKind.Data); await Assert.That(message.ErrorMap.Statuses.Select(static status => status.StatusCode) .SequenceEqual([400, 401, 404])).IsTrue(); await Assert.That(message.ErrorMap.Statuses[2].Tags.Select(static tag => tag.Tag) @@ -112,7 +112,7 @@ await Assert.That(part.Parameters.Select(static parameter => parameter.Name) await Assert.That(part.Envelope.AdapterTypeName).IsEqualTo("GadgetPartResponseAdapter"); await Assert.That(part.Envelope.PayloadName).IsEqualTo("Part"); await Assert.That(part.Envelope.PayloadTypeName).IsEqualTo("GadgetPart"); - await Assert.That(part.Envelope.HasDataEnvelope).IsTrue(); + await Assert.That(part.Envelope.Kind).IsEqualTo(EnvelopeKind.Data); await Assert.That(part.ErrorMap.Statuses.Single().StatusCode).IsEqualTo(404); await Assert.That(part.ErrorMap.Statuses.Single().Tags.Single().TypeName).IsEqualTo("GadgetMissingError"); } @@ -373,6 +373,67 @@ public async Task Bind_Should_Leave_Options_Absent_When_An_Operation_Has_No_Quer await Assert.That(part.Options).IsNull(); } + [Test] + public async Task Bind_Should_Bind_A_Cursor_List_Envelope() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario()); + + 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.CursorList); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Registry.TypeNames + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Bind_A_Component_Data_Envelope_Without_Modeling_The_Wrapper() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("WidgetResponse", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("data", property => property.Ref("WidgetInfo"), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetResponse"))))); + + 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.Data); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(plan.Models.Select(static model => model.Name) + .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Cursor_List_With_A_Malformed_Cursor() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(cursor => cursor.Type("object") + .AdditionalPropertiesFalse() + .Property("previous", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null")), required: true) + .Property("next", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))))); + + await AssertWidgetRefusalAsync(document, "cursor object"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Cursor_List_Whose_Items_Are_Not_Component_References() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(items: items => items.Type("object") + .Property("id", property => property.Type("string"), required: true))); + + await AssertWidgetRefusalAsync(document, "array of a named component schema"); + } + [Test] public async Task Bind_Should_Refuse_A_Required_Query_Parameter() { @@ -688,6 +749,28 @@ private static SpecScenario WidgetListScenario(Action configur _ = operation.Response(200, "application/json", schema => schema.Ref("WidgetInfo")); })); + private static SpecScenario CursorListScenario(Action? cursor = null, Action? items = null) => + SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("WidgetsResponse", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("data", property => property + .Type("array") + .Items(items ?? (static item => item.Ref("WidgetInfo"))), required: true) + .Property("cursor", cursor ?? DefaultCursor, required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetsResponse")))); + + private static void DefaultCursor(SchemaBuilder cursor) => cursor.Type("object") + .AdditionalPropertiesFalse() + .Property("previous", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))) + .Property("next", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))); + private static SpecScenario WidgetCreateScenario(Action configureBody, string mediaType = "application/json", bool required = true) => SpecScenario.Define(spec => spec diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 8b63dd0e2..5e4e5df15 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -103,7 +103,7 @@ private static OperationPlan CreatePingOperation() => AdapterTypeName = "PingResponseAdapter", PayloadName = "Ping", PayloadTypeName = "ExampleItem", - HasDataEnvelope = false, + Kind = EnvelopeKind.Bare, }, ErrorMap = new ErrorMapPlan { @@ -135,7 +135,7 @@ private static OperationPlan CreateOverviewOperation() => AdapterTypeName = "WidgetOverviewResponseAdapter", PayloadName = "Overview", PayloadTypeName = "ExampleItem", - HasDataEnvelope = false, + Kind = EnvelopeKind.Bare, }, ErrorMap = new ErrorMapPlan { @@ -170,7 +170,7 @@ private static OperationPlan CreateItemOperation() => AdapterTypeName = "WidgetItemResponseAdapter", PayloadName = "Item", PayloadTypeName = "ExampleItem", - HasDataEnvelope = true, + Kind = EnvelopeKind.Data, }, ErrorMap = new ErrorMapPlan { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs new file mode 100644 index 000000000..61ee295f5 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopeKind.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// Identifies how the wire success body carries the payload. +internal enum EnvelopeKind +{ + /// The body is the payload itself. + Bare = 0, + + /// The body wraps the payload in a required data property. + Data = 1, + + /// The body carries a data array beside a previous/next cursor object. + CursorList = 2, +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs index e17e50256..375f3d350 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs @@ -8,8 +8,8 @@ internal sealed record EnvelopePlan 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 a value indicating whether the wire success body wraps the payload in a data property. - public required bool HasDataEnvelope { get; init; } + public required EnvelopeKind Kind { get; init; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 4cc9a4cbb..810eb9513 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -353,10 +353,9 @@ private void CheckStatusShape() { SpecEnvelopeShape.Bare => BindBarePayload(success.Schema), SpecEnvelopeShape.Data => BindDataPayload(success.Schema), - SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation or SpecEnvelopeShape.CursorData - or SpecEnvelopeShape.DataHasMore - => RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported in M1"), - _ => RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported in M1"), + SpecEnvelopeShape.CursorData => BindCursorListPayload(success.Schema), + SpecEnvelopeShape.None or SpecEnvelopeShape.DataLocation or SpecEnvelopeShape.DataHasMore or _ => + RefuseNull($"envelope shape '{success.EnvelopeShape}' is not supported"), }; if (payload is null) { @@ -383,7 +382,13 @@ or SpecEnvelopeShape.DataHasMore AdapterTypeName = $"{responseTypeName}Adapter", PayloadName = payloadName, PayloadTypeName = payload, - HasDataEnvelope = success.EnvelopeShape is SpecEnvelopeShape.Data, + 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, + }, }; } @@ -408,6 +413,53 @@ or SpecEnvelopeShape.DataHasMore return RefuseNull("envelope payload must be a required reference to a named schema"); } + private string? BindCursorListPayload(SchemaNode schema) + { + if (schema is not RefNode reference + || !_document.Schemas.TryGetValue(reference.Target, out var target) + || target is not ObjectNode wrapper) + { + return RefuseNull("cursor-list envelope must reference an object schema"); + } + + var data = wrapper.Properties.FirstOrDefault(static property => property.Name is "data"); + var cursor = wrapper.Properties.FirstOrDefault(static property => property.Name is "cursor"); + if (wrapper.Properties.Count is not 2 || data is not { IsRequired: true } || cursor is not { IsRequired: true }) + { + return RefuseNull("cursor-list envelope must require exactly 'data' and 'cursor'"); + } + + if (!IsListCursorShape(cursor.Schema)) + { + return RefuseNull("cursor-list 'cursor' must be the optional-nullable previous/next cursor object"); + } + + // 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 not ArrayNode { Item: RefNode item } + || item.Target.Contains('#', StringComparison.Ordinal) + || !_typeNames.TryGetValue(item.Target, out var itemName)) + { + return RefuseNull("cursor-list 'data' must be an array of a named component schema"); + } + + return itemName; + } + + /// Recognizes the wire cursor contract: exactly optional-nullable string previous and next. + private bool IsListCursorShape(SchemaNode schema) + { + if (Resolve(schema) is not ObjectNode cursor + || cursor.Properties.Count is not 2 + || cursor.Properties.Select(static property => property.Name).Distinct(StringComparer.Ordinal).Count() is not 2) + { + return false; + } + + return cursor.Properties.All(property => property is { IsRequired: false, Name: "previous" or "next" } + && Resolve(property.Schema) is NullableNode { Inner: PrimitiveNode { Kind: PrimitiveKind.String } }); + } + private ErrorMapPlan? BindErrorMap() { var statuses = new List(); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs index 46623867c..b455ec48a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs @@ -31,7 +31,7 @@ public ReachableSchemaSet Collect(SpecDocument document, IReadOnlyList _keys = new(comparer); private readonly HashSet _responseRoots = new(comparer); - public void VisitResponse(string operationId, SchemaNode? schema) + public void VisitResponse(string operationId, SpecResponse response) { ArgumentException.ThrowIfNullOrWhiteSpace(operationId); - if (schema is RefNode reference && reference.Target.StartsWith($"op:{operationId}#", StringComparison.Ordinal)) + ArgumentNullException.ThrowIfNull(response); + + var schema = response.Schema; + if (schema is RefNode reference) { - _ = _responseRoots.Add(reference.Target); + // 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; + if (isInlineRoot || isWrapperShape) + { + _ = _responseRoots.Add(reference.Target); + } + + if (response.EnvelopeShape is SpecEnvelopeShape.CursorData) + { + ExcludeCursorObject(reference.Target); + } } if (schema is not null) @@ -60,6 +75,23 @@ public void VisitResponse(string operationId, SchemaNode? schema) } } + /// The cursor object rides the envelope contract (hand-written spine), never the model closure. + private void ExcludeCursorObject(string wrapperKey) + { + if (!_graph.TryGetValue(wrapperKey, out var wrapperSchema) || wrapperSchema is not ObjectNode wrapper) + { + return; + } + + foreach (var property in wrapper.Properties) + { + if (property is { Name: "cursor", Schema: RefNode cursorReference }) + { + _ = _responseRoots.Add(cursorReference.Target); + } + } + } + public void Visit(SchemaNode schema) { ArgumentNullException.ThrowIfNull(schema); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs index ca2729ac5..18f299a0a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs @@ -129,7 +129,13 @@ private static MethodDeclarationSyntax EmitAdapt(OperationPlan operation) private static ObjectCreationExpressionSyntax EmitSuccessCreation(EnvelopePlan envelope) { - var reader = envelope.HasDataEnvelope ? "ReadDataPayload" : "ReadBarePayload"; + var reader = envelope.Kind switch + { + EnvelopeKind.Bare => "ReadBarePayload", + EnvelopeKind.Data => "ReadDataPayload", + EnvelopeKind.CursorList or _ => + throw new InvalidOperationException($"Envelope kind '{envelope.Kind}' has no adapter reader yet."), + }; var payload = EmissionSyntax.Invocation( SyntaxFactory.IdentifierName(reader), SyntaxFactory.Argument(SyntaxFactory.IdentifierName("rawBody")), From 2336603de7411ef6ea12069d9f77d9267c4d3c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:24:37 +0300 Subject: [PATCH 06/47] feat(generator): collapse curated schema aliases before binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schemaAliases curation section declares one component as a spelling of another — the resolution sealed for upstream's InvalidRequestError1 duplicate, whose identical _tag would otherwise trip the per-status duplicate-tag wall and poison tag dispatch. The collapse is a pure document transform ahead of name resolution, so downstream machinery never sees the duplicate; union branches that fold into the same reference deduplicate mechanically. Every drift direction stays loud: a deleted source or target orphans the row, a dereferenced source goes dormant, and any structural divergence — the tag included — fails the deep-identity wall. The binder's own duplicate-tag refusal stays intact for anomalies without an alias. --- .../Fixtures/Binding/alias-curation.json | 16 ++ .../Binding/unknown-curation-field.json | 1 + .../Fixtures/Binding/valid-curation.json | 3 +- .../Generator/Binding/CurationLoaderTests.cs | 14 ++ .../Generator/Binding/SpecBinderTests.cs | 163 ++++++++++++++++++ .../Support/BindingScenarioData.cs | 12 +- .../Support/BindingTestHost.cs | 1 + .../Support/GenerationTestData.cs | 3 +- .../Generator/Binding/CurationValidator.cs | 61 +++++++ .../Binding/Models/GenerationCuration.cs | 11 ++ .../Generator/Binding/Models/SchemaAlias.cs | 19 ++ .../Generator/Binding/SchemaAliasApplier.cs | 96 +++++++++++ .../Generator/Binding/SchemaNodeComparer.cs | 65 +++++++ .../Generator/Binding/SpecBinder.cs | 40 +++++ tools/OpenCode.Sdk.Tools/ToolApp.cs | 1 + tools/curation.json | 3 +- 16 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaAlias.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaAliasApplier.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json new file mode 100644 index 000000000..08843da76 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/alias-curation.json @@ -0,0 +1,16 @@ +{ + "groups": { + "health": { + "placement": "root" + } + }, + "envelopePayloadNames": {}, + "propertyOverrides": [], + "schemaAliases": [ + { + "schema": "InvalidRequestError1", + "aliasOf": "InvalidRequestError", + "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." + } + ] +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json index c02350068..c6ed2ebde 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json @@ -6,5 +6,6 @@ }, "envelopePayloadNames": {}, "propertyOverrides": [], + "schemaAliases": [], "mystery": true } 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 4eb66d7a3..92753efb2 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json +++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json @@ -5,5 +5,6 @@ } }, "envelopePayloadNames": {}, - "propertyOverrides": [] + "propertyOverrides": [], + "schemaAliases": [] } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs index f9827c192..22bd0722c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs @@ -20,6 +20,20 @@ public async Task LoadAsync_Should_Read_Strict_Curation() await Assert.That(curation.Groups["health"].Placement).IsEqualTo(GroupPlacement.Root); await Assert.That(curation.EnvelopePayloadNames).IsEmpty(); await Assert.That(curation.PropertyOverrides).IsEmpty(); + await Assert.That(curation.SchemaAliases).IsEmpty(); + } + + [Test] + public async Task LoadAsync_Should_Read_Schema_Alias_Rows() + { + var fileSystem = CreateFileSystem("Binding.alias-curation.json"); + + var curation = await new CurationLoader(fileSystem).LoadAsync(CurationPath, CancellationToken.None); + + var alias = curation.SchemaAliases.Single(); + await Assert.That(alias.Schema).IsEqualTo("InvalidRequestError1"); + await Assert.That(alias.AliasOf).IsEqualTo("InvalidRequestError"); + await Assert.That(alias.Reason).Contains("duplicate"); } [Test] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index f40aee093..5b298ac70 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -349,6 +349,169 @@ await Assert.That(exception.Errors.Single(static error => error.Category == Bind .Contains("duplicated"); } + [Test] + public async Task Bind_Should_Collapse_A_Structurally_Identical_Schema_Alias() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(Alias("GadgetError1", "GadgetError"))); + + var gadget = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + var status = gadget.ErrorMap.Statuses.Single(static entry => entry.StatusCode == 400); + await Assert.That(status.Tags.Select(static tag => tag.TypeName) + .SequenceEqual(["GadgetError"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "GadgetError1")).IsFalse(); + await Assert.That(plan.Unions.Single(static union => union.Name == "OpenCodeError") + .Variants.Select(static variant => variant.TypeName) + .SequenceEqual(["GadgetError"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Duplicate_Error_Tag_Without_An_Alias() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration())); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Operation + && error.Problem.Contains("duplicate error tag", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Shapes_Differ() + { + var document = await IngestAsync(DuplicateTagScenario(duplicate => duplicate.Type("object") + .Property("_tag", property => property.Type("string").Enum("GadgetError"), required: true) + .Property("message", property => property.Type("string"), required: true) + .Property("detail", property => property.Type("string")))); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError"), "structurally identical"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Schema_Is_Missing() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GhostError", "GadgetError"), "does not exist", subject: "GhostError"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Target_Is_Missing() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GhostError"), "does not exist"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Self_Alias() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError1"), "itself"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Chained_Alias() + { + var document = await IngestAsync(SpecScenario.Define(spec => + { + DefineDuplicateTagSpec(spec, DefaultDuplicate); + _ = spec.WithSchema("GadgetError2", DefaultDuplicate); + })); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(Alias("GadgetError2", "GadgetError1"), Alias("GadgetError1", "GadgetError")))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("chain", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Duplicated_Alias_Source() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(Alias("GadgetError1", "GadgetError"), Alias("GadgetError1", "GadgetError")))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("duplicated", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Alias_Without_A_Reason() + { + var document = await IngestAsync(DuplicateTagScenario()); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError", reason: " "), "reason"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Dormant_Alias() + { + var document = await IngestAsync(SpecScenario.Define(spec => + { + DefineDuplicateTagSpec(spec, DefaultDuplicate); + _ = spec.WithSchema("LonelyError", DefaultDuplicate) + .WithSchema("LonelyError1", DefaultDuplicate); + })); + + await AssertAliasRefusalAsync( + document, + Alias("LonelyError1", "LonelyError"), + "not referenced", + subject: "LonelyError1"); + } + + private static async Task AssertAliasRefusalAsync(SpecDocument document, SchemaAlias alias, string expectedProblem, + string? subject = null) + { + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + GadgetCuration(alias))); + + await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCategory.Curation + && string.Equals(error.Subject, subject ?? alias.Schema, StringComparison.Ordinal) + && error.Problem.Contains(expectedProblem, StringComparison.Ordinal))).IsTrue(); + } + + private static SpecScenario DuplicateTagScenario(Action? duplicate = null) => + SpecScenario.Define(spec => DefineDuplicateTagSpec(spec, duplicate ?? DefaultDuplicate)); + + private static void DefineDuplicateTagSpec(SpecDocumentBuilder spec, Action duplicate) => + _ = spec + .WithSchema("GadgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("GadgetError", DefaultDuplicate) + .WithSchema("GadgetError1", duplicate) + .WithOperation("v2.gadget.get", path: "/api/gadget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("GadgetInfo")) + .Response(400, "application/json", schema => schema.AnyOf( + static branch => branch.Ref("GadgetError1"), + static branch => branch.Ref("GadgetError")))); + + private static void DefaultDuplicate(SchemaBuilder schema) => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("GadgetError"), required: true) + .Property("message", property => property.Type("string"), required: true); + + private static GenerationCuration GadgetCuration(params SchemaAlias[] aliases) => + Curation( + Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null)), + schemaAliases: aliases); + private static async Task<(SpecDocument Document, OperationSelection Selection, GenerationCuration Curation)> LoadPinnedInputsAsync() { var fileSystem = new RealFileSystem(); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs index d800cbc2d..d317d7036 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingScenarioData.cs @@ -13,12 +13,22 @@ public static OperationSelection Selection(params string[] operationIds) => public static GenerationCuration Curation( IReadOnlyDictionary groups, IReadOnlyDictionary? envelopePayloadNames = null, - IReadOnlyList? propertyOverrides = null) => + IReadOnlyList? propertyOverrides = null, + IReadOnlyList? schemaAliases = null) => new() { Groups = groups, EnvelopePayloadNames = envelopePayloadNames ?? new Dictionary(StringComparer.Ordinal), PropertyOverrides = propertyOverrides ?? [], + SchemaAliases = schemaAliases ?? [], + }; + + public static SchemaAlias Alias(string schema, string aliasOf, string reason = "The upstream spec emits a duplicate component.") => + new() + { + Schema = schema, + AliasOf = aliasOf, + Reason = reason, }; public static Dictionary Groups(string wireName, GroupCuration group) => diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs index c5ee5ed43..e3b03d19a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs @@ -15,6 +15,7 @@ public BindingTestHost() _binder = new SpecBinder( new ReachableSchemaCollector(), new CurationValidator(), + new SchemaAliasApplier(), new SchemaNameResolver(), new SchemaPlanBinder(), new OperationPlanBinder()); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs index a603d7ba2..321752b47 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs @@ -23,7 +23,8 @@ internal static class GenerationTestData } }, "envelopePayloadNames": {}, - "propertyOverrides": [] + "propertyOverrides": [], + "schemaAliases": [] } """; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index 02cde23e1..70b76e1a0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -23,6 +23,7 @@ public void Validate(SpecDocument document, IReadOnlyList selecte ValidateGroups(selected, selectedGroups, documentGroups, curation, errors); ValidateEnvelopeNames(selectedIds, documentIds, curation, errors); ValidatePropertyOverrides(document, reachable, curation, errors); + ValidateSchemaAliases(document, reachable, curation, errors); } private static void ValidateGroups(IReadOnlyList selected, HashSet selectedGroups, @@ -187,5 +188,65 @@ private static void ValidatePropertyOverrides(SpecDocument document, ReachableSc } } + /// + /// The alias walls carry the drift contract: a deleted source or target orphans the row, + /// a dereferenced source goes dormant, and any structural divergence — the tag included — + /// breaks the identity check. Every upstream move on the duplicate is loud. + /// + private static void ValidateSchemaAliases(SpecDocument document, ReachableSchemaSet reachable, + GenerationCuration curation, BindingErrorCollector errors) + { + var reachableKeys = reachable.GraphKeys.ToHashSet(StringComparer.Ordinal); + var sources = new HashSet(StringComparer.Ordinal); + foreach (var alias in curation.SchemaAliases) + { + if (!sources.Add(alias.Schema)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema alias is duplicated"); + } + + if (string.IsNullOrWhiteSpace(alias.Reason)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema alias must declare a reason"); + } + + if (string.Equals(alias.Schema, alias.AliasOf, StringComparison.Ordinal)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema alias cannot target itself"); + continue; + } + + if (!document.Schemas.TryGetValue(alias.Schema, out var source)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schema does not exist in the spec"); + continue; + } + + if (!document.Schemas.TryGetValue(alias.AliasOf, out var target)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, $"alias target '{alias.AliasOf}' does not exist in the spec"); + continue; + } + + if (!reachableKeys.Contains(alias.Schema)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schema is not referenced by the selected profile"); + } + + if (!SchemaNodeComparer.DeepEquals(source, target)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "aliased schemas must be structurally identical"); + } + } + + foreach (var alias in curation.SchemaAliases + .Where(alias => !string.Equals(alias.Schema, alias.AliasOf, StringComparison.Ordinal) + && sources.Contains(alias.AliasOf)) + .OrderBy(static alias => alias.Schema, StringComparer.Ordinal)) + { + errors.Add(BindingErrorCategory.Curation, alias.Schema, "schema aliases cannot chain"); + } + } + private static string GetGroup(SpecOperation operation) => operation.Segments[0]; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs index d40cb38ca..3e2895f71 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs @@ -37,4 +37,15 @@ public required IReadOnlyList PropertyOverrides field = Array.AsReadOnly([.. value]); } } = Array.AsReadOnly(Array.Empty()); + + [JsonPropertyName("schemaAliases")] + public required IReadOnlyList SchemaAliases + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } = Array.AsReadOnly(Array.Empty()); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaAlias.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaAlias.cs new file mode 100644 index 000000000..d159af778 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaAlias.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tools.Generator.Binding.Models; + +/// +/// Declares one component schema as a spelling of another; the collapse is validated +/// fail-closed for structural identity so upstream drift on either side breaks generation. +/// +internal sealed record SchemaAlias +{ + [JsonPropertyName("schema")] + public required string Schema { get; init; } + + [JsonPropertyName("aliasOf")] + public required string AliasOf { get; init; } + + [JsonPropertyName("reason")] + public required string Reason { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaAliasApplier.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaAliasApplier.cs new file mode 100644 index 000000000..289f02831 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaAliasApplier.cs @@ -0,0 +1,96 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +/// +/// Collapses curated schema aliases before binding: every reference to an aliased key is +/// rewritten to its target and the aliased schema leaves the graph, so downstream machinery +/// never sees the duplicate. Container kinds without a rewrite rule pass through unchanged; +/// a reference they might carry then dangles and fails reachability loudly. +/// +internal sealed class SchemaAliasApplier +{ + public SpecDocument Apply(SpecDocument document, IReadOnlyList aliases) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(aliases); + + if (aliases.Count is 0) + { + return document; + } + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var alias in aliases) + { + map[alias.Schema] = alias.AliasOf; + } + + var schemas = new Dictionary(StringComparer.Ordinal); + foreach (var (key, schema) in document.Schemas) + { + if (!map.ContainsKey(key)) + { + schemas[key] = Rewrite(schema, map); + } + } + + return new SpecDocument + { + Operations = [.. document.Operations.Select(operation => RewriteOperation(operation, map))], + Schemas = schemas, + }; + } + + private SpecOperation RewriteOperation(SpecOperation operation, Dictionary map) => + operation with + { + Parameters = [.. operation.Parameters.Select(parameter => parameter with { Schema = Rewrite(parameter.Schema, map) })], + RequestBody = operation.RequestBody is null + ? null + : operation.RequestBody with { Schema = Rewrite(operation.RequestBody.Schema, map) }, + Responses = + [ + .. operation.Responses.Select(response => response.Schema is null + ? response + : response with { Schema = Rewrite(response.Schema, map) }), + ], + }; + + private SchemaNode Rewrite(SchemaNode node, Dictionary map) => node switch + { + RefNode reference when map.TryGetValue(reference.Target, out var target) => reference with { Target = target }, + NullableNode nullable => nullable with { Inner = Rewrite(nullable.Inner, map) }, + ArrayNode array => array with { Item = Rewrite(array.Item, map) }, + TupleNode tuple => tuple with { Items = [.. tuple.Items.Select(item => Rewrite(item, map))] }, + DictionaryNode dictionary => dictionary with { Value = Rewrite(dictionary.Value, map) }, + UnionNode union => RewriteUnion(union, map), + ObjectNode target => target with + { + Properties = [.. target.Properties.Select(property => property with { Schema = Rewrite(property.Schema, map) })], + AdditionalPropertiesSchema = target.AdditionalPropertiesSchema is null + ? null + : Rewrite(target.AdditionalPropertiesSchema, map), + }, + _ => node, + }; + + /// Collapsed branches can duplicate a reference; the duplicates fold away mechanically. + private UnionNode RewriteUnion(UnionNode union, Dictionary map) + { + var branches = new List(union.Branches.Count); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var branch in union.Branches.Select(entry => Rewrite(entry, map))) + { + if (branch is RefNode reference && !seen.Add(reference.Target)) + { + continue; + } + + branches.Add(branch); + } + + return union with { Branches = branches }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs new file mode 100644 index 000000000..6d1dd842d --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs @@ -0,0 +1,65 @@ +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +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. +/// +internal static class SchemaNodeComparer +{ + public static bool DeepEquals(SchemaNode left, SchemaNode right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + + 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) => 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, + }; + } + + 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)); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 6cbacb5a7..7439f15f1 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -8,6 +8,7 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; internal sealed class SpecBinder( ReachableSchemaCollector reachableSchemas, CurationValidator curationValidator, + SchemaAliasApplier schemaAliases, SchemaNameResolver schemaNames, SchemaPlanBinder schemaPlans, OperationPlanBinder operationPlans) : ISpecBinder @@ -15,6 +16,7 @@ internal sealed class SpecBinder( private readonly CurationValidator _curationValidator = curationValidator ?? throw new ArgumentNullException(nameof(curationValidator)); private readonly OperationPlanBinder _operationPlans = operationPlans ?? throw new ArgumentNullException(nameof(operationPlans)); private readonly ReachableSchemaCollector _reachableSchemas = reachableSchemas ?? throw new ArgumentNullException(nameof(reachableSchemas)); + private readonly SchemaAliasApplier _schemaAliases = schemaAliases ?? throw new ArgumentNullException(nameof(schemaAliases)); private readonly SchemaNameResolver _schemaNames = schemaNames ?? throw new ArgumentNullException(nameof(schemaNames)); private readonly SchemaPlanBinder _schemaPlans = schemaPlans ?? throw new ArgumentNullException(nameof(schemaPlans)); @@ -30,6 +32,17 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera var reachable = _reachableSchemas.Collect(document, selected, errors); _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. + 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); + } + // Type names are resolved exactly once per bind; schema and operation binding // consume the same map so neither can reinterpret a schema under another name. var typeNames = _schemaNames.Resolve(document, reachable, errors); @@ -57,6 +70,33 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }; } + /// + /// 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/ToolApp.cs b/tools/OpenCode.Sdk.Tools/ToolApp.cs index d7a336b5e..3457c30a0 100644 --- a/tools/OpenCode.Sdk.Tools/ToolApp.cs +++ b/tools/OpenCode.Sdk.Tools/ToolApp.cs @@ -36,6 +36,7 @@ public static ServiceCollection CreateServices(Action? overr services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/tools/curation.json b/tools/curation.json index 29192073d..f9fbcf288 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -24,5 +24,6 @@ "type": "uri", "reason": "The wire field carries a URI, but the pinned schema omits format: uri." } - ] + ], + "schemaAliases": [] } From affd8dbf344e688ce45b76552c29f09ba3e63502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:28:25 +0300 Subject: [PATCH 07/47] feat(generator): merge wire groups sharing a curated client name v2.message.list belongs to the 'message' wire group but its sealed home is the Sessions client family. Client assembly now clusters by declared client name instead of wire group, and the curation validator demands identical handle declarations across a merged family so a divergent row cannot fork it. A pinned-shape test also documents that 5xx arms ride the existing status-map machinery unchanged. --- .../Binding/OperationPlanBinderTests.cs | 79 +++++++++++++++++++ .../Generator/Binding/CurationValidator.cs | 19 +++++ .../Generator/Binding/OperationPlanBinder.cs | 5 +- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 63248aa9e..fe331516c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -434,6 +434,85 @@ public async Task Bind_Should_Refuse_A_Cursor_List_Whose_Items_Are_Not_Component await AssertWidgetRefusalAsync(document, "array of a named component schema"); } + [Test] + public async Task Bind_Should_Merge_Groups_Sharing_A_Client_Name() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario(spec => spec + .WithOperation("v2.gizmo.list", path: "/api/gadget/{gadgetID}/gizmo", configure: operation => operation + .Parameter("gadgetID", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Ref("GadgetPart"), required: true))))); + + var groups = new Dictionary(StringComparer.Ordinal) + { + ["gadget"] = ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"), + ["gizmo"] = ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"), + }; + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part", "v2.gizmo.list"), + Curation(groups)); + + var root = plan.Clients.Single(static client => client.Role == ClientRole.Root); + await Assert.That(root.SubClients.Single().TypeName).IsEqualTo("GadgetsClient"); + var collection = plan.Clients.Single(static client => client.Role == ClientRole.Collection); + await Assert.That(collection.Operations).IsEmpty(); + var handle = plan.Clients.Single(static client => client.Role == ClientRole.Handle); + await Assert.That(handle.Operations.Select(static operation => operation.MethodName) + .Order(StringComparer.Ordinal) + .SequenceEqual(["GetListAsync", "GetPartAsync"], StringComparer.Ordinal)).IsTrue(); + await Assert.That(handle.Operations.All(static operation => operation.RouteContainerName == "Gadgets")).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_Merged_Groups_With_Diverging_Handles() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario(spec => spec + .WithOperation("v2.gizmo.list", path: "/api/gadget/{gadgetID}/gizmo", configure: operation => operation + .Parameter("gadgetID", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Ref("GadgetPart"), required: true))))); + + var groups = new Dictionary(StringComparer.Ordinal) + { + ["gadget"] = ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"), + ["gizmo"] = ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null), + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part", "v2.gizmo.list"), + Curation(groups))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("identical handle", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Bind_A_500_Error_Arm() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("UnknownFailure", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("UnknownFailure"), required: true) + .Property("message", property => property.Type("string"), required: true)) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ItemInfo")) + .Response(500, "application/json", schema => schema.Ref("UnknownFailure"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(Groups("health", RootGroup()))); + + var health = plan.Clients.Single(static client => client.Role == ClientRole.Root).Operations.Single(); + var status = health.ErrorMap.Statuses.Single(); + await Assert.That(status.StatusCode).IsEqualTo(500); + await Assert.That(status.Tags.Single().TypeName).IsEqualTo("UnknownFailure"); + } + [Test] public async Task Bind_Should_Refuse_A_Required_Query_Parameter() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index 70b76e1a0..e3a1cd662 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -47,6 +47,25 @@ private static void ValidateGroups(IReadOnlyList selected, HashSe ValidateGroupShape(wireName, group, selected, errors); } + + // Groups sharing a client name merge into one client family, so their handle + // declarations must agree exactly — a divergent row would fork the family. + foreach (var clientName in curation.Groups + .Where(static pair => pair.Value is { Placement: GroupPlacement.Client, ClientName: not null }) + .GroupBy(static pair => pair.Value.ClientName!, StringComparer.Ordinal) + .Where(static family => family + .Select(static pair => (pair.Value.HandleName, pair.Value.HandleParameter)) + .Distinct() + .Skip(1) + .Any()) + .Select(static family => family.Key) + .Order(StringComparer.Ordinal)) + { + errors.Add( + BindingErrorCategory.Curation, + clientName, + $"groups sharing client '{clientName}' must declare identical handle configuration"); + } } private static void ValidateGroupShape(string wireName, GroupCuration group, IReadOnlyList selected, BindingErrorCollector errors) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 810eb9513..55c6e671f 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -57,9 +57,12 @@ private List AssembleClients(List bound) .ToArray(); var subClients = new List(); var clients = new List(); + + // Wire groups sharing a curated client name merge into one client family; the + // curation validator has already demanded identical handle declarations. foreach (var group in bound .Where(static operation => operation.Row.Placement is GroupPlacement.Client) - .GroupBy(static operation => operation.Group, _comparer) + .GroupBy(static operation => operation.Row.ClientName!, _comparer) .OrderBy(static group => group.Key, _comparer)) { var members = group.ToArray(); From 8a4caf539e44a13136593a9f6c514826767ab804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:33:51 +0300 Subject: [PATCH 08/47] feat(sdk): grow the pipeline body path for JSON requests ExecuteAsync gains a body-carrying overload serializing through source-generated JsonTypeInfo metadata so the AOT commitment holds; both overloads share one core, keeping decoration, buffering, and error-channel policy identical. The body rides ByteArrayContent under a bare application/json content type (JSON is UTF-8 by RFC 8259), the body-less path stays body-free, and the recording test seam now captures request content and content type. --- src/OpenCode.Sdk/Internal/Pipeline.cs | 35 ++++++++- tests/OpenCode.Sdk.Tests/PipelineTests.cs | 71 +++++++++++++++++++ .../Support/RecordedRequest.cs | 4 ++ .../Support/RecordingHttpHandler.cs | 6 +- tests/OpenCode.Sdk.Tests/Support/TestBody.cs | 9 +++ .../Support/TestBodyJsonContext.cs | 7 ++ 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/TestBody.cs create mode 100644 tests/OpenCode.Sdk.Tests/Support/TestBodyJsonContext.cs diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 177806a60..4fa62e5f4 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Net.Http.Headers; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using OpenCode.Sdk.Internal.Abstractions; namespace OpenCode.Sdk.Internal; @@ -69,8 +71,26 @@ public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions optio return new Pipeline(httpClient, ownsHttpClient: false, options.Endpoint, options.Password, environment); } - public async Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter, + public Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter, OpenCodeRequestOptions? options, CancellationToken cancellationToken) + where TResponse : OpenCodeResponse => + ExecuteCoreAsync(method, route, body: null, bodyTypeInfo: null, adapter, options, cancellationToken); + + public Task ExecuteAsync(HttpMethod method, string route, TBody body, + JsonTypeInfo bodyTypeInfo, ResponseAdapter adapter, OpenCodeRequestOptions? options, + CancellationToken cancellationToken) + where TBody : class + where TResponse : OpenCodeResponse + { + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(bodyTypeInfo); + return ExecuteCoreAsync(method, route, body, bodyTypeInfo, adapter, options, cancellationToken); + } + + private async Task ExecuteCoreAsync(HttpMethod method, string route, TBody? body, + JsonTypeInfo? bodyTypeInfo, ResponseAdapter adapter, OpenCodeRequestOptions? options, + CancellationToken cancellationToken) + where TBody : class where TResponse : OpenCodeResponse { ObjectDisposedException.ThrowIf(_disposed, this); @@ -93,6 +113,11 @@ public async Task ExecuteAsync(HttpMethod method, string r string rawBody; using (var request = new HttpRequestMessage(method, new Uri(_endpointBase + route, UriKind.Absolute))) { + if (body is not null) + { + request.Content = CreateJsonContent(body, bodyTypeInfo!); + } + Decorate(request); using var response = await SendCoreAsync(request, cancellationToken).ConfigureAwait(false); status = (int)response.StatusCode; @@ -105,6 +130,14 @@ public async Task ExecuteAsync(HttpMethod method, string r : adapted; } + /// JSON is UTF-8 by definition (RFC 8259); the content type carries no charset. + private static ByteArrayContent CreateJsonContent(TBody body, JsonTypeInfo bodyTypeInfo) + { + var content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body, bodyTypeInfo)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return content; + } + public void Dispose() { if (_disposed) diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 3e20177e3..afcd0fcd8 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -109,6 +109,77 @@ public async Task ExecuteAsync_Should_Never_Touch_The_Client_Defaults() await Assert.That(httpClient.DefaultRequestHeaders.Any()).IsFalse(); } + [Test] + public async Task ExecuteAsync_Should_Send_The_Json_Body_With_The_Json_Content_Type() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + _ = await pipeline.ExecuteAsync( + HttpMethod.Post, + "/api/session", + new TestBody { Value = "created" }, + TestBodyJsonContext.Default.TestBody, + new RecordingResponseAdapter(), + options: null, + CancellationToken.None); + + await Assert.That(handler.Requests.Single().Method).IsEqualTo(HttpMethod.Post); + await Assert.That(handler.Requests.Single().ContentType).IsEqualTo("application/json"); + await Assert.That(handler.Requests.Single().Body).IsEqualTo("""{"value":"created"}"""); + } + + [Test] + public async Task ExecuteAsync_Should_Keep_The_Bodyless_Path_Body_Free() + { + 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); + + await Assert.That(handler.Requests.Single().Body).IsNull(); + await Assert.That(handler.Requests.Single().ContentType).IsNull(); + } + + [Test] + public async Task ExecuteAsync_Should_Guard_The_Body_Arguments() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + _ = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Post, "/api/session", null!, TestBodyJsonContext.Default.TestBody, + new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + _ = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Post, "/api/session", new TestBody { Value = "created" }, bodyTypeInfo: null!, + new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task ExecuteAsync_Should_Guard_The_Route_On_The_Body_Path() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Post, "api/session", new TestBody { Value = "created" }, TestBodyJsonContext.Default.TestBody, + new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("Routes must start with '/'"); + await Assert.That(handler.Requests).IsEmpty(); + } + [Test] public async Task ExecuteAsync_Should_Buffer_The_Body_For_The_Adapter() { diff --git a/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs b/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs index 4aef9b079..c0a200206 100644 --- a/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs +++ b/tests/OpenCode.Sdk.Tests/Support/RecordedRequest.cs @@ -9,4 +9,8 @@ internal sealed record RecordedRequest public string? Authorization { get; init; } public string? UserAgent { get; init; } + + public string? ContentType { get; init; } + + public string? Body { get; init; } } diff --git a/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs b/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs index 1eafbe11d..5e91e7ea6 100644 --- a/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs +++ b/tests/OpenCode.Sdk.Tests/Support/RecordingHttpHandler.cs @@ -26,7 +26,7 @@ public RecordingHttpHandler(Func respon public bool IsDisposed { get; private set; } - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { _requests.Add(new RecordedRequest { @@ -34,8 +34,10 @@ protected override Task SendAsync(HttpRequestMessage reques Method = request.Method, Authorization = request.Headers.Authorization?.ToString(), UserAgent = request.Headers.UserAgent.Count is 0 ? null : request.Headers.UserAgent.ToString(), + ContentType = request.Content?.Headers.ContentType?.ToString(), + Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken), }); - return Task.FromResult(_responder(request)); + return _responder(request); } protected override void Dispose(bool disposing) diff --git a/tests/OpenCode.Sdk.Tests/Support/TestBody.cs b/tests/OpenCode.Sdk.Tests/Support/TestBody.cs new file mode 100644 index 000000000..ab9385021 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/TestBody.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tests.Support; + +internal sealed record TestBody +{ + [JsonPropertyName("value")] + public required string Value { get; init; } +} diff --git a/tests/OpenCode.Sdk.Tests/Support/TestBodyJsonContext.cs b/tests/OpenCode.Sdk.Tests/Support/TestBodyJsonContext.cs new file mode 100644 index 000000000..c6fcf58e5 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/TestBodyJsonContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Tests.Support; + +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(TestBody))] +internal sealed partial class TestBodyJsonContext : JsonSerializerContext; From 75c8e9497c01c91f990d17608fa2b6da222abf4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:44:23 +0300 Subject: [PATCH 09/47] feat(generator): seal the List/Create verb rules and the naming walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verb detection is structural: only the final identifier segment can be a verb (create/get/list), so mid-position segments spelling a verb stay in the subject — the C18 fix. Empty subjects fall back to the group, pluralized for list operations under a naive fail-closed rule (words needing -es or -ies refuse until the rule set grows). Response types fold non-Get verbs so get/create envelopes cannot collide; client-placed route members mirror their method names so merged families stay unique, while root members keep their bare-verb shape. The naming walls batch rides along: reserved keywords stop passing identifier validation (C20), the reserved payload set covers object/record-synthesized members (C19), and mechanical payload names pass the same identifier wall as curated ones (C17). The committed two-operation surface is byte-identical under the new rules. --- .../Binding/CSharpNamePolicyTests.cs | 11 ++ .../Binding/OperationNamePolicyTests.cs | 113 ++++++++++++++++++ .../Binding/OperationPlanBinderTests.cs | 28 ++++- .../Generator/Binding/CSharpNamePolicy.cs | 6 + .../Generator/Binding/OperationNamePolicy.cs | 108 ++++++++++++----- .../Generator/Binding/OperationPlanBinder.cs | 50 +++++++- 6 files changed, 278 insertions(+), 38 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs index 7c246ef0a..6113d71de 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CSharpNamePolicyTests.cs @@ -17,4 +17,15 @@ public async Task ToPascalCase_Should_Use_Ordinary_Acronym_Casing(string wireNam await Assert.That(result).IsEqualTo(expected); } + + [Test] + [Arguments("class", false)] + [Arguments("string", false)] + [Arguments("record", true)] + [Arguments("Session", true)] + [Arguments("_reserved", true)] + public async Task IsValidIdentifier_Should_Reject_Reserved_Keywords(string candidate, bool expected) + { + await Assert.That(CSharpNamePolicy.IsValidIdentifier(candidate)).IsEqualTo(expected); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs new file mode 100644 index 000000000..5573cd5c9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs @@ -0,0 +1,113 @@ +using OpenCode.Sdk.Tools.Generator.Binding; +using OpenCode.Sdk.Tools.Generator.Binding.Models; +using OpenCode.Sdk.Tools.Generator.Ingestion.Models; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Binding; + +public sealed class OperationNamePolicyTests +{ + [Test] + [Arguments("v2.health.get", "get", "GetHealthAsync")] + [Arguments("v2.session.message", "get", "GetMessageAsync")] + [Arguments("v2.session.get", "get", "GetSessionAsync")] + [Arguments("v2.session.list", "get", "ListSessionsAsync")] + [Arguments("v2.session.create", "post", "CreateSessionAsync")] + [Arguments("v2.message.list", "get", "ListMessagesAsync")] + 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); + } + + [Test] + public async Task MethodName_Should_Keep_A_Mid_Position_Verb_Segment_In_The_Subject() + { + await Assert.That(OperationNamePolicy.MethodName(Operation("v2.cache.get.entry"))) + .IsEqualTo("GetGetEntryAsync"); + } + + [Test] + public async Task MethodName_Should_Return_Null_When_The_Group_Cannot_Be_Pluralized() + { + await Assert.That(OperationNamePolicy.MethodName(Operation("v2.boss.list"))).IsNull(); + } + + [Test] + [Arguments("v2.health.get", "get", "HealthResponse")] + [Arguments("v2.session.message", "get", "SessionMessageResponse")] + [Arguments("v2.session.get", "get", "SessionResponse")] + [Arguments("v2.session.list", "get", "SessionListResponse")] + [Arguments("v2.session.create", "post", "SessionCreateResponse")] + [Arguments("v2.message.list", "get", "MessageListResponse")] + 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); + } + + [Test] + [Arguments("v2.health.get", "get", false, "Get")] + [Arguments("v2.session.message", "get", true, "GetMessage")] + [Arguments("v2.session.get", "get", true, "GetSession")] + [Arguments("v2.session.list", "get", true, "ListSessions")] + [Arguments("v2.session.create", "post", true, "CreateSession")] + [Arguments("v2.message.list", "get", true, "ListMessages")] + public async Task RouteMemberName_Should_Fall_Back_To_The_Group_On_Client_Placement_Only(string operationId, + string method, bool clientPlacement, string expected) + { + var placement = clientPlacement ? GroupPlacement.Client : GroupPlacement.Root; + + await Assert.That(OperationNamePolicy.RouteMemberName(Operation(operationId, method), placement)).IsEqualTo(expected); + } + + [Test] + [Arguments("v2.health.get", "get", "Health")] + [Arguments("v2.session.message", "get", "Message")] + [Arguments("v2.session.get", "get", "Session")] + [Arguments("v2.session.list", "get", "Sessions")] + [Arguments("v2.session.create", "post", "Session")] + [Arguments("v2.message.list", "get", "Messages")] + public async Task PayloadName_Should_Pluralize_The_Group_For_List_Operations(string operationId, string method, + string expected) + { + await Assert.That(OperationNamePolicy.PayloadName(Operation(operationId, method))).IsEqualTo(expected); + } + + [Test] + public async Task PayloadName_Should_Return_Null_When_The_Group_Cannot_Be_Pluralized() + { + await Assert.That(OperationNamePolicy.PayloadName(Operation("v2.boss.list"))).IsNull(); + } + + [Test] + [Arguments("v2.session.list", "SessionListOptions")] + [Arguments("v2.message.list", "MessageListOptions")] + public async Task OptionsTypeName_Should_Compose_Group_Subject_And_Verb(string operationId, string expected) + { + await Assert.That(OperationNamePolicy.OptionsTypeName(Operation(operationId))).IsEqualTo(expected); + } + + [Test] + public async Task RequestTypeName_Should_Compose_Group_Subject_And_Verb() + { + await Assert.That(OperationNamePolicy.RequestTypeName(Operation("v2.session.create", "post"))) + .IsEqualTo("SessionCreateRequest"); + } + + private static SpecOperation Operation(string operationId, string method = "get") + { + ArgumentException.ThrowIfNullOrWhiteSpace(operationId); + + return new SpecOperation + { + OperationId = operationId, + Segments = [.. operationId.Split('.').Skip(1)], + Method = method, + Path = "/api/x", + HasWildcardPath = false, + IsWebSocket = false, + IsSse = false, + IsDeprecated = false, + Parameters = [], + Responses = [], + }; + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index fe331516c..1f18d0946 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -461,7 +461,7 @@ public async Task Bind_Should_Merge_Groups_Sharing_A_Client_Name() var handle = plan.Clients.Single(static client => client.Role == ClientRole.Handle); await Assert.That(handle.Operations.Select(static operation => operation.MethodName) .Order(StringComparer.Ordinal) - .SequenceEqual(["GetListAsync", "GetPartAsync"], StringComparer.Ordinal)).IsTrue(); + .SequenceEqual(["GetPartAsync", "ListGizmosAsync"], StringComparer.Ordinal)).IsTrue(); await Assert.That(handle.Operations.All(static operation => operation.RouteContainerName == "Gadgets")).IsTrue(); } @@ -680,7 +680,7 @@ public async Task Bind_Should_Refuse_A_Wildcard_Path() public async Task Bind_Should_Refuse_Colliding_Method_Names_On_A_Client() { var document = await BindingTestHost.IngestAsync(GadgetScenario(spec => spec - .WithOperation("v2.gadget.get.part", path: "/api/gadget/{gadgetID}/part-alias/{partID}", configure: operation => operation + .WithOperation("v2.gadget.part.get", path: "/api/gadget/{gadgetID}/part-alias/{partID}", configure: operation => operation .Parameter("gadgetID", "path", schema => schema.Type("string"), required: true) .Parameter("partID", "path", schema => schema.Type("string"), required: true) .Response(200, "application/json", schema => schema.Type("object") @@ -688,7 +688,7 @@ public async Task Bind_Should_Refuse_Colliding_Method_Names_On_A_Client() var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( document, - Selection("v2.gadget.part", "v2.gadget.get.part"), + Selection("v2.gadget.part", "v2.gadget.part.get"), Curation(Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID"))))); await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming @@ -762,6 +762,28 @@ await Assert.That(exception.Errors.Single(static error => error.Category == Bind .Contains("Status"); } + [Test] + [Arguments("EqualityContract")] + [Arguments("ToString")] + [Arguments("GetHashCode")] + [Arguments("PrintMembers")] + public async Task Bind_Should_Refuse_A_Payload_Name_Colliding_With_Record_Members(string payloadName) + { + var document = await BindingTestHost.IngestAsync(StatusScenario()); + var payloadNames = new Dictionary(StringComparer.Ordinal) + { + ["v2.widget.status"] = payloadName, + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.status"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)), payloadNames))); + + await Assert.That(exception.Errors.Any(error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains(payloadName, StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Accept_An_Override_That_Resolves_A_Reserved_Payload_Collision() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs index f5943077f..7e50b5807 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs @@ -41,6 +41,12 @@ public static bool IsValidIdentifier(string candidate) return false; } + // Reserved keywords would emit broken source; contextual keywords are legal identifiers. + if (Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(candidate) is not Microsoft.CodeAnalysis.CSharp.SyntaxKind.None) + { + return false; + } + return candidate.Skip(1).All(static character => char.IsLetterOrDigit(character) || character is '_'); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index 4e4efe861..c7bf5d482 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -3,13 +3,16 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; -/// Derives C# member and type names for one bound operation from its identifier segments. +/// +/// Derives C# member and type names for one bound operation from its identifier segments. +/// Verb detection is structural — only the final segment can be a verb — so mid-position +/// segments that spell a verb stay in the subject. Derivations that need the pluralized +/// group return when the naive rule is unsafe; the binder refuses +/// such operations until the rule set grows. +/// internal static class OperationNamePolicy { - /// - /// Identifier segments recognized as operation verbs when they close the identifier; - /// verb detection is structural (final position only), never value-filtering. - /// + /// Identifier segments recognized as operation verbs when they close the identifier. private static readonly string[] KnownVerbSegments = ["create", "get", "list"]; /// Gets the operation verb: a recognized final identifier segment, or the HTTP method. @@ -22,69 +25,114 @@ public static string Verb(SpecOperation operation) : CSharpNamePolicy.ToPascalCase(operation.Method); } - public static string OptionsTypeName(SpecOperation operation) + public static string? MethodName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{MiddleSubject(operation)}{Verb(operation)}Options"; + var subject = SubjectOrGroupFallback(operation); + return subject is null ? null : $"{Verb(operation)}{subject}Async"; } - public static string RequestTypeName(SpecOperation operation) + public static string ResponseTypeName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{MiddleSubject(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}Response"; } - public static string MethodName(SpecOperation operation, GroupPlacement placement) + /// + /// Route members never restate a root container; client-placed members mirror their + /// method names so merged client families stay collision-free. + /// + public static string? RouteMemberName(SpecOperation operation, GroupPlacement placement) { ArgumentNullException.ThrowIfNull(operation); - var subject = Subject(operation); - if (subject.Length is 0 && placement is GroupPlacement.Root) + if (placement is GroupPlacement.Root) { - subject = CSharpNamePolicy.ToPascalCase(operation.Segments[0]); + return $"{Verb(operation)}{Subject(operation)}"; } - return $"{CSharpNamePolicy.ToPascalCase(operation.Method)}{subject}Async"; + var subject = SubjectOrGroupFallback(operation); + return subject is null ? null : $"{Verb(operation)}{subject}"; } - public static string ResponseTypeName(SpecOperation operation) + public static string? PayloadName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}Response"; + var subject = Subject(operation); + return subject.Length is 0 ? GroupFallback(operation) : subject; } - public static string RouteMemberName(SpecOperation operation) + public static string OptionsTypeName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); - return $"{CSharpNamePolicy.ToPascalCase(operation.Method)}{Subject(operation)}"; + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{Verb(operation)}Options"; } - public static string PayloadName(SpecOperation operation) + public static string RequestTypeName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); + return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{Verb(operation)}Request"; + } + + /// + /// The operation's subject: the segments between the group and the closing verb. + /// The final segment also drops when it restates the HTTP method without being a + /// recognized verb; nothing mid-position is ever dropped. + /// + private static string Subject(SpecOperation operation) + { + var end = operation.Segments.Count; + if (HasVerbSegment(operation) + || (end > 1 && string.Equals(operation.Segments[^1], operation.Method, StringComparison.Ordinal))) + { + end--; + } + + return string.Concat(operation.Segments.Skip(1).Take(end - 1).Select(CSharpNamePolicy.ToPascalCase)); + } + + private static string? SubjectOrGroupFallback(SpecOperation operation) + { var subject = Subject(operation); - return subject.Length is 0 ? CSharpNamePolicy.ToPascalCase(operation.Segments[0]) : subject; + return subject.Length is 0 ? GroupFallback(operation) : subject; } - /// The operation's subject: every segment after the group that does not restate the HTTP method. - private static string Subject(SpecOperation operation) => - string.Concat(operation.Segments - .Skip(1) - .Where(segment => !string.Equals(segment, operation.Method, StringComparison.Ordinal)) - .Select(CSharpNamePolicy.ToPascalCase)); + /// An empty subject falls back to the group — pluralized for list operations. + private static string? GroupFallback(SpecOperation operation) + { + var group = CSharpNamePolicy.ToPascalCase(operation.Segments[0]); + return string.Equals(Verb(operation), "List", StringComparison.Ordinal) ? Pluralize(group) : group; + } - /// The segments between the group and the closing verb segment, when one exists. - private static string MiddleSubject(SpecOperation operation) + /// + /// Naive pluralization only: words needing -es or -ies return + /// and the operation refuses until the rule set grows deliberately. + /// + private static string? Pluralize(string word) { - var count = operation.Segments.Count - (HasVerbSegment(operation) ? 2 : 1); - return string.Concat(operation.Segments.Skip(1).Take(count).Select(CSharpNamePolicy.ToPascalCase)); + if (word[^1] is 's' or 'x' or 'z' + || word.EndsWith("ch", StringComparison.Ordinal) || word.EndsWith("sh", StringComparison.Ordinal)) + { + return null; + } + + if (word.Length > 1 && word[^1] is 'y' && !IsVowel(word[^2])) + { + return null; + } + + return $"{word}s"; } + private static bool IsVowel(char letter) => letter is 'a' or 'e' or 'i' or 'o' or 'u' or 'A' or 'E' or 'I' or 'O' or 'U'; + private static bool HasVerbSegment(SpecOperation operation) => operation.Segments.Count > 1 && KnownVerbSegments.Contains(operation.Segments[^1], StringComparer.Ordinal); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 55c6e671f..95c650452 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -11,10 +11,26 @@ internal sealed class OperationPlanBinder private const string RootClientName = "OpenCodeClient"; /// - /// Member names every generated response envelope inherits from the response spine; a - /// payload landing on one of them needs a curated override. + /// Member names every generated response envelope inherits from the response spine or + /// from /record synthesis; a payload landing on one of them needs a + /// curated override. /// - private static readonly string[] ReservedPayloadNames = ["Error", "IsError", "RawBody", "Status"]; + private static readonly string[] ReservedPayloadNames = + [ + "Deconstruct", + "EqualityContract", + "Equals", + "Error", + "GetHashCode", + "GetType", + "IsError", + "MemberwiseClone", + "PrintMembers", + "RawBody", + "ReferenceEquals", + "Status", + "ToString", + ]; private readonly StringComparer _comparer = StringComparer.Ordinal; @@ -239,6 +255,14 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation var optionalPlanErrorsBefore = _errors.Count; var options = BindQueryOptions(); var requestBody = BindRequestBody(); + var methodName = OperationNamePolicy.MethodName(_operation); + var routeMemberName = OperationNamePolicy.RouteMemberName(_operation, row.Placement); + if (methodName is null || routeMemberName is null) + { + Refuse("the operation's names cannot be derived mechanically: the group does not pluralize naively"); + return null; + } + if (envelope is null || errorMap is null || parameters is null || _errors.Count != optionalPlanErrorsBefore) { return null; @@ -252,11 +276,11 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation IsHandleOperation = parameters.Any(static parameter => parameter.IsHandleParameter), Plan = new OperationPlan { - MethodName = OperationNamePolicy.MethodName(_operation, row.Placement), + MethodName = methodName, HttpMethod = _operation.Method, RouteTemplate = _operation.Path, RouteContainerName = row.ClientName ?? CSharpNamePolicy.ToPascalCase(group), - RouteMemberName = OperationNamePolicy.RouteMemberName(_operation), + RouteMemberName = routeMemberName, Parameters = parameters, Options = options, RequestBody = requestBody, @@ -369,6 +393,22 @@ private void CheckStatusShape() var payloadName = _curation.EnvelopePayloadNames.TryGetValue(_operation.OperationId, out var curated) ? curated : OperationNamePolicy.PayloadName(_operation); + if (payloadName is null) + { + Refuse("the payload name cannot be derived mechanically: the group does not pluralize naively; curate an envelope payload name"); + return null; + } + + // Mechanical names are PascalCase by construction, but the wall covers both origins. + if (!CSharpNamePolicy.IsValidIdentifier(payloadName)) + { + _errors.Add( + BindingErrorCategory.Naming, + _operation.OperationId, + $"payload name '{payloadName}' is not a valid C# identifier"); + return null; + } + if (ReservedPayloadNames.Contains(payloadName, StringComparer.Ordinal) || string.Equals(payloadName, responseTypeName, StringComparison.Ordinal)) { From 165eb0544793034dfed1984d70c57a87d6b026a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 02:49:13 +0300 Subject: [PATCH 10/47] feat(sdk): add the list-pagination and parent-filter spine types ListOptions carries the sealed cursor-pagination trio as the seam the M3 paginator will consume; ListOrder spells asc/desc without magic strings; ListCursor surfaces the wire cursor on list envelopes; SessionParentFilter makes the root-only sentinel unrepresentable as ordinary data and refuses the literal 'null' spelling. The internal QueryStringBuilder owns the route-boundary conversions: invariant positive counts (non-positive refused, the sealed int? limit contract), wire order spellings, escaped values, omitted unset optionals. PublicApi baseline grows by exactly these four types, identically across TFMs. --- .../Internal/QueryStringBuilder.cs | 72 ++++++++++++ src/OpenCode.Sdk/ListCursor.cs | 15 +++ src/OpenCode.Sdk/ListOptions.cs | 17 +++ src/OpenCode.Sdk/ListOrder.cs | 11 ++ src/OpenCode.Sdk/SessionParentFilter.cs | 34 ++++++ .../QueryStringBuilderTests.cs | 111 ++++++++++++++++++ .../SessionParentFilterTests.cs | 41 +++++++ .../Snapshots/PublicApi.verified.txt | 25 ++++ 8 files changed, 326 insertions(+) create mode 100644 src/OpenCode.Sdk/Internal/QueryStringBuilder.cs create mode 100644 src/OpenCode.Sdk/ListCursor.cs create mode 100644 src/OpenCode.Sdk/ListOptions.cs create mode 100644 src/OpenCode.Sdk/ListOrder.cs create mode 100644 src/OpenCode.Sdk/SessionParentFilter.cs create mode 100644 tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/SessionParentFilterTests.cs diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs new file mode 100644 index 000000000..b2764cf4c --- /dev/null +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using System.Text; + +namespace OpenCode.Sdk.Internal; + +/// +/// Composes the query-string suffix of a generated route; unset optionals are omitted and +/// every value is escaped. Route builders own the wire conversion rules, so refusals here +/// are the sealed route-boundary contract. +/// +internal sealed class QueryStringBuilder +{ + private StringBuilder? _builder; + + public string Value => _builder?.ToString() ?? string.Empty; + + public void AddText(string name, string? value) + { + if (value is not null) + { + Append(name, value); + } + } + + public void AddCount(string name, int? value) + { + if (value is null) + { + return; + } + + if (value <= 0) + { + throw new ArgumentException($"The '{name}' query value must be positive.", nameof(value)); + } + + Append(name, value.Value.ToString(CultureInfo.InvariantCulture)); + } + + public void AddOrder(string name, ListOrder? value) + { + if (value is null) + { + return; + } + + var wire = value switch + { + ListOrder.Ascending => "asc", + ListOrder.Descending => "desc", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown ListOrder value."), + }; + Append(name, wire); + } + + public void AddParentFilter(string name, SessionParentFilter? value) + { + if (value is not null) + { + Append(name, value.WireValue); + } + } + + private void Append(string name, string value) + { + _builder ??= new StringBuilder(); + _ = _builder.Append(_builder.Length is 0 ? '?' : '&') + .Append(name) + .Append('=') + .Append(Uri.EscapeDataString(value)); + } +} diff --git a/src/OpenCode.Sdk/ListCursor.cs b/src/OpenCode.Sdk/ListCursor.cs new file mode 100644 index 000000000..a6cc5808c --- /dev/null +++ b/src/OpenCode.Sdk/ListCursor.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk; + +/// The wire cursor of a list page; absent directions mean no further page. +public sealed record ListCursor +{ + /// Gets the opaque cursor of the previous page, when one exists. + [JsonPropertyName("previous")] + public string? Previous { get; init; } + + /// Gets the opaque cursor of the next page, when one exists. + [JsonPropertyName("next")] + public string? Next { get; init; } +} diff --git a/src/OpenCode.Sdk/ListOptions.cs b/src/OpenCode.Sdk/ListOptions.cs new file mode 100644 index 000000000..e8f834be5 --- /dev/null +++ b/src/OpenCode.Sdk/ListOptions.cs @@ -0,0 +1,17 @@ +namespace OpenCode.Sdk; + +/// +/// The cursor-pagination trio shared by list operations whose wire parameters match it +/// exactly; generated per-operation options records derive from this seam. +/// +public abstract record ListOptions +{ + /// Gets the maximum number of entries to return; the server default applies when unset. + public int? Limit { get; init; } + + /// Gets the first-page order; the server default applies when unset. + public ListOrder? Order { get; init; } + + /// Gets the opaque pagination cursor returned by a previous page. + public string? Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/ListOrder.cs b/src/OpenCode.Sdk/ListOrder.cs new file mode 100644 index 000000000..1b49b3fdd --- /dev/null +++ b/src/OpenCode.Sdk/ListOrder.cs @@ -0,0 +1,11 @@ +namespace OpenCode.Sdk; + +/// The page order of a list operation's first page. +public enum ListOrder +{ + /// Oldest entries first. + Ascending = 0, + + /// Newest entries first. + Descending = 1, +} diff --git a/src/OpenCode.Sdk/SessionParentFilter.cs b/src/OpenCode.Sdk/SessionParentFilter.cs new file mode 100644 index 000000000..499fc7637 --- /dev/null +++ b/src/OpenCode.Sdk/SessionParentFilter.cs @@ -0,0 +1,34 @@ +namespace OpenCode.Sdk; + +/// +/// Filters a session list by parent: a concrete parent's children, or root sessions only. +/// The wire spells root-only as the literal string "null"; this type keeps that +/// sentinel unrepresentable as ordinary data. +/// +public sealed class SessionParentFilter +{ + private const string RootOnlyWireValue = "null"; + + private SessionParentFilter(string wireValue) => WireValue = wireValue; + + /// Gets the filter returning root sessions only. + public static SessionParentFilter RootOnly { get; } = new(RootOnlyWireValue); + + /// Gets the raw wire value. + internal string WireValue { get; } + + /// Creates a filter returning the children of one session. + /// The parent session identifier. + /// The filter. + /// The identifier is blank or spells the root-only sentinel. + public static SessionParentFilter Of(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (string.Equals(sessionId, RootOnlyWireValue, StringComparison.Ordinal)) + { + throw new ArgumentException("The literal 'null' is the root-only sentinel; use RootOnly instead.", nameof(sessionId)); + } + + return new SessionParentFilter(sessionId); + } +} diff --git a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs new file mode 100644 index 000000000..755202991 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs @@ -0,0 +1,111 @@ +using OpenCode.Sdk.Internal; + +namespace OpenCode.Sdk.Tests; + +public sealed class QueryStringBuilderTests +{ + [Test] + public async Task Value_Should_Be_Empty_When_Nothing_Was_Added() + { + var query = new QueryStringBuilder(); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } + + [Test] + public async Task AddText_Should_Skip_Null_Values() + { + var query = new QueryStringBuilder(); + + query.AddText("search", null); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } + + [Test] + public async Task AddText_Should_Join_Values_With_Query_Separators() + { + var query = new QueryStringBuilder(); + + query.AddText("search", "alpha"); + query.AddText("cursor", "beta"); + + await Assert.That(query.Value).IsEqualTo("?search=alpha&cursor=beta"); + } + + [Test] + public async Task AddText_Should_Escape_The_Value() + { + var query = new QueryStringBuilder(); + + query.AddText("search", "a b&c=d"); + + await Assert.That(query.Value).IsEqualTo("?search=a%20b%26c%3Dd"); + } + + [Test] + public async Task AddCount_Should_Write_The_Invariant_Wire_String() + { + var query = new QueryStringBuilder(); + + query.AddCount("limit", 50); + + await Assert.That(query.Value).IsEqualTo("?limit=50"); + } + + [Test] + [Arguments(0)] + [Arguments(-1)] + public async Task AddCount_Should_Refuse_A_Non_Positive_Value(int limit) + { + var query = new QueryStringBuilder(); + + var exception = Assert.Throws(() => query.AddCount("limit", limit)); + + await Assert.That(exception.Message).Contains("positive"); + } + + [Test] + [Arguments(ListOrder.Ascending, "?order=asc")] + [Arguments(ListOrder.Descending, "?order=desc")] + public async Task AddOrder_Should_Write_The_Wire_Spelling(ListOrder order, string expected) + { + var query = new QueryStringBuilder(); + + query.AddOrder("order", order); + + await Assert.That(query.Value).IsEqualTo(expected); + } + + [Test] + public async Task AddOrder_Should_Refuse_An_Undefined_Value() + { + var query = new QueryStringBuilder(); + + _ = Assert.Throws(() => query.AddOrder("order", (ListOrder)7)); + await Task.CompletedTask; + } + + [Test] + public async Task AddParentFilter_Should_Write_The_Wire_Value() + { + var query = new QueryStringBuilder(); + + query.AddParentFilter("parentID", SessionParentFilter.RootOnly); + query.AddParentFilter("other", SessionParentFilter.Of("ses_1")); + + await Assert.That(query.Value).IsEqualTo("?parentID=null&other=ses_1"); + } + + [Test] + public async Task Add_Should_Skip_Every_Null_Optional() + { + var query = new QueryStringBuilder(); + + query.AddCount("limit", null); + query.AddOrder("order", null); + query.AddParentFilter("parentID", null); + + await Assert.That(query.Value).IsEqualTo(string.Empty); + } +} diff --git a/tests/OpenCode.Sdk.Tests/SessionParentFilterTests.cs b/tests/OpenCode.Sdk.Tests/SessionParentFilterTests.cs new file mode 100644 index 000000000..c14ee73ec --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/SessionParentFilterTests.cs @@ -0,0 +1,41 @@ + +namespace OpenCode.Sdk.Tests; + +public sealed class SessionParentFilterTests +{ + [Test] + public async Task RootOnly_Should_Carry_The_Literal_Null_Wire_Value() + { + await Assert.That(SessionParentFilter.RootOnly.WireValue).IsEqualTo("null"); + } + + [Test] + public async Task RootOnly_Should_Be_A_Singleton() + { + await Assert.That(ReferenceEquals(SessionParentFilter.RootOnly, SessionParentFilter.RootOnly)).IsTrue(); + } + + [Test] + public async Task Of_Should_Carry_The_Identifier() + { + await Assert.That(SessionParentFilter.Of("ses_123").WireValue).IsEqualTo("ses_123"); + } + + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" ")] + public async Task Of_Should_Refuse_A_Blank_Identifier(string? sessionId) + { + _ = Assert.Throws(() => _ = SessionParentFilter.Of(sessionId!)); + await Task.CompletedTask; + } + + [Test] + public async Task Of_Should_Refuse_The_Literal_Null_Spelling() + { + var exception = Assert.Throws(() => _ = SessionParentFilter.Of("null")); + + await Assert.That(exception.Message).Contains("RootOnly"); + } +} diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 2690f098c..3b7292298 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -11,6 +11,26 @@ public required OpenCode.Sdk.Models.ServiceHealth Health { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } + public sealed class ListCursor : System.IEquatable + { + public ListCursor() { } + [System.Text.Json.Serialization.JsonPropertyName("next")] + public string? Next { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("previous")] + public string? Previous { get; init; } + } + public abstract class ListOptions : System.IEquatable + { + protected ListOptions() { } + public string? Cursor { get; init; } + public int? Limit { get; init; } + public OpenCode.Sdk.ListOrder? Order { get; init; } + } + public enum ListOrder + { + Ascending = 0, + Descending = 1, + } public class OpenCodeApiException : OpenCode.Sdk.OpenCodeException { public OpenCodeApiException() { } @@ -87,6 +107,11 @@ public required OpenCode.Sdk.Models.SessionMessageInfo Message { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } + public sealed class SessionParentFilter + { + public static OpenCode.Sdk.SessionParentFilter RootOnly { get; } + public static OpenCode.Sdk.SessionParentFilter Of(string sessionId) { } + } public class SessionsClient { protected SessionsClient() { } From e9821ad3dae4ee3fbab668a0a23d855227d37503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 03:02:36 +0300 Subject: [PATCH 11/47] feat(generator): emit options records, request bodies, and cursor-list envelopes The emitters open every M1 wall the binder now crosses. New emitters render public options records (flat, or deriving from the ListOptions base) and the internal envelope DTOs behind P2's single-pass read: every wrapped success body deserializes once through source-generated metadata, retiring the JsonDocument double-parse and the hand-written ReadDataPayload primitive. Route builders compose escaped query suffixes through QueryStringBuilder; cursor-list envelopes carry a guarded IReadOnlyList payload plus the wire cursor; adapters project DTOs directly; operation methods thread body models (an omitted optional body still sends the empty JSON object) and query options, and the per-call OpenCodeRequestOptions parameter is uniformly named requestOptions. Committed two-operation surface regenerated under the new emitters; snapshots and the PublicApi baseline reviewed alongside. --- src/OpenCode.Sdk/.generated-manifest.json | 1 + src/OpenCode.Sdk/Internal/ResponseAdapter.cs | 28 --- .../SessionMessageResponseAdapter.cs | 2 +- .../Serialization/OpenCodeJsonContext.cs | 1 + .../SessionMessageResponseEnvelope.cs | 14 ++ src/OpenCode.Sdk/OpenCodeClient.cs | 6 +- src/OpenCode.Sdk/SessionClient.cs | 6 +- .../ResponseAdapterTests.cs | 21 -- .../Snapshots/PublicApi.verified.txt | 4 +- .../Binding/OperationPlanBinderTests.cs | 3 +- .../Emission/EnvelopeDtoEmitterTests.cs | 15 ++ .../Generator/Emission/OptionsEmitterTests.cs | 15 ++ ...ce_Virtual_Delegating_Clients.verified.txt | 66 +++++- ...ould_Render_The_Envelope_Dtos.verified.txt | 69 ++++++ ...ce_Guarded_Response_Envelopes.verified.txt | 198 ++++++++++++++++++ ...ld_Render_The_Options_Records.verified.txt | 37 ++++ ...ce_Generated_Context_Registry.verified.txt | 5 + ...roduce_Status_Mapped_Adapters.verified.txt | 133 +++++++++++- ...roduce_Escaped_Route_Builders.verified.txt | 60 ++++++ .../Support/EmitterPlanFixture.cs | 169 ++++++++++++++- .../Generator/Binding/Models/EnvelopePlan.cs | 6 + .../Generator/Binding/OperationPlanBinder.cs | 17 +- .../Generator/Binding/SpecBinder.cs | 15 +- .../Generator/Emission/ClientEmitter.cs | 2 + .../Generator/Emission/EnvelopeDtoEmitter.cs | 82 ++++++++ .../Generator/Emission/EnvelopeEmitter.cs | 85 ++++++-- .../Emission/OperationMethodEmitter.cs | 108 ++++++++-- .../Generator/Emission/OptionsEmitter.cs | 79 +++++++ .../Emission/ResponseAdapterEmitter.cs | 72 +++++-- .../Generator/Emission/RoutesEmitter.cs | 100 ++++++++- .../Generator/Emission/SourceEmitter.cs | 2 + 31 files changed, 1283 insertions(+), 138 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionMessageResponseEnvelope.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/EnvelopeDtoEmitterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/OptionsEmitterTests.cs create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt create mode 100644 tests/OpenCode.Sdk.Tools.Tests/Snapshots/OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs create mode 100644 tools/OpenCode.Sdk.Tools/Generator/Emission/OptionsEmitter.cs diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index e68c919aa..3850e26fa 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -10,6 +10,7 @@ "Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs", "Internal/Serialization/SessionMessageCompactionJsonConverter.cs", "Internal/Serialization/SessionMessageInfoJsonConverter.cs", + "Internal/Serialization/SessionMessageResponseEnvelope.cs", "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", "Internal/Serialization/ToolContentJsonConverter.cs", "Models/InvalidRequestError.cs", diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs index 6f882b27a..b4daf2ae2 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapter.cs @@ -38,34 +38,6 @@ protected static TPayload ReadBarePayload(string rawBody, JsonTypeInfo } } - /// Reads a {"data": ...} success payload; a malformed envelope is a protocol failure. - /// The payload type. - /// The buffered success body. - /// The source-generated payload metadata. - /// The payload. - protected static TPayload ReadDataPayload(string rawBody, JsonTypeInfo typeInfo) - { - ArgumentNullException.ThrowIfNull(rawBody); - ArgumentNullException.ThrowIfNull(typeInfo); - - try - { - using var document = JsonDocument.Parse(rawBody); - if (document.RootElement.ValueKind is not JsonValueKind.Object - || !document.RootElement.TryGetProperty("data", out var data)) - { - throw new OpenCodeTransportException("The opencode API returned a success body without its 'data' envelope."); - } - - return JsonSerializer.Deserialize(data, typeInfo) - ?? throw new OpenCodeTransportException("The opencode API returned a null success payload."); - } - catch (JsonException exception) - { - throw new OpenCodeTransportException("The opencode API returned a malformed success body.", exception); - } - } - /// Builds the protocol failure for a success status the operation does not declare. /// The undeclared 2xx status code. /// The transport failure the adapter throws. diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs index 2f47d645c..402d151b3 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageResponseAdapter.cs @@ -31,7 +31,7 @@ public override SessionMessageResponse Adapt(int status, string rawBody) 200 => new SessionMessageResponse { Status = status, - Message = ReadDataPayload(rawBody, OpenCodeJsonContext.Default.SessionMessageInfo) + Message = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionMessageResponseEnvelope).Data }, >= 200 and < 300 => throw UndeclaredSuccessFailure(status), 400 => new SessionMessageResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs index 560f5924b..861486ed8 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs @@ -48,6 +48,7 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(SessionMessageLocationSwitchedTime))] [JsonSerializable(typeof(SessionMessageModelSelected))] [JsonSerializable(typeof(SessionMessageModelSelectedTime))] +[JsonSerializable(typeof(SessionMessageResponseEnvelope))] [JsonSerializable(typeof(SessionMessageShell))] [JsonSerializable(typeof(SessionMessageShellOutput))] [JsonSerializable(typeof(SessionMessageShellStatus))] diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageResponseEnvelope.cs new file mode 100644 index 000000000..63ff24c67 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageResponseEnvelope.cs @@ -0,0 +1,14 @@ +// 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 'SessionMessageResponse' through one deserialization pass. +/// +internal sealed record SessionMessageResponseEnvelope +{ + [JsonPropertyName("data")] + public required SessionMessageInfo Data { get; init; } +} diff --git a/src/OpenCode.Sdk/OpenCodeClient.cs b/src/OpenCode.Sdk/OpenCodeClient.cs index 90fc48d1e..d5534e764 100644 --- a/src/OpenCode.Sdk/OpenCodeClient.cs +++ b/src/OpenCode.Sdk/OpenCodeClient.cs @@ -78,13 +78,13 @@ protected virtual void Dispose(bool disposing) /// /// Check server health /// - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'HealthResponse' 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 GetHealthAsync(OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetHealthAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Health.Get, HealthResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Health.Get, HealthResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/src/OpenCode.Sdk/SessionClient.cs b/src/OpenCode.Sdk/SessionClient.cs index 0a148ca63..45f8a4679 100644 --- a/src/OpenCode.Sdk/SessionClient.cs +++ b/src/OpenCode.Sdk/SessionClient.cs @@ -33,14 +33,14 @@ protected SessionClient() /// Get session message /// /// The 'messageID' route value. - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'SessionMessageResponse' 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 GetMessageAsync(string messageId, OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetMessageAsync(string messageId, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(messageId); - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.GetMessage(SessionId, messageId), SessionMessageResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.GetMessage(SessionId, messageId), SessionMessageResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs b/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs index 749315bb4..c01b594d5 100644 --- a/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs +++ b/tests/OpenCode.Sdk.Tests/ResponseAdapterTests.cs @@ -33,24 +33,6 @@ public async Task ReadBarePayload_Should_Treat_Null_Bodies_As_Protocol_Failures( _ = ProbeAdapter.Bare("null", OpenCodeJsonContext.Default.ServiceHealth)); } - [Test] - public async Task ReadDataPayload_Should_Unwrap_The_Data_Envelope() - { - var payload = ProbeAdapter.Data( - "{\"data\":{\"healthy\":false,\"version\":\"2\",\"pid\":9}}", - OpenCodeJsonContext.Default.ServiceHealth); - - await Assert.That(payload.Healthy).IsFalse(); - await Assert.That(payload.Version).IsEqualTo("2"); - } - - [Test] - public async Task ReadDataPayload_Should_Refuse_A_Missing_Data_Property() - { - _ = Assert.Throws(() => - _ = ProbeAdapter.Data("{\"other\":1}", OpenCodeJsonContext.Default.ServiceHealth)); - } - [Test] public async Task ReadTolerantError_Should_Keep_A_Known_Tag_At_Its_Declared_Status() { @@ -99,9 +81,6 @@ private sealed class ProbeAdapter : ResponseAdapter public static TPayload Bare(string rawBody, JsonTypeInfo typeInfo) => ReadBarePayload(rawBody, typeInfo); - public static TPayload Data(string rawBody, JsonTypeInfo typeInfo) => - ReadDataPayload(rawBody, typeInfo); - public static OpenCodeError? Error(string rawBody, IReadOnlyCollection? allowedTags) => ReadTolerantError(rawBody, allowedTags); diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 3b7292298..6714d4aa8 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -50,7 +50,7 @@ public virtual OpenCode.Sdk.SessionsClient Sessions { get; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } - public virtual System.Threading.Tasks.Task GetHealthAsync(OpenCode.Sdk.OpenCodeRequestOptions? options = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task GetHealthAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } public sealed class OpenCodeClientOptions { @@ -99,7 +99,7 @@ public class SessionClient { protected SessionClient() { } - public virtual System.Threading.Tasks.Task GetMessageAsync(string messageId, OpenCode.Sdk.OpenCodeRequestOptions? options = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task GetMessageAsync(string messageId, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } public sealed class SessionMessageResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 1f18d0946..3302daeaa 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -383,10 +383,11 @@ public async Task Bind_Should_Bind_A_Cursor_List_Envelope() var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); await Assert.That(list.Envelope.Kind).IsEqualTo(EnvelopeKind.CursorList); await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("WidgetInfo"); + await Assert.That(list.Envelope.EnvelopeDtoTypeName).IsEqualTo("WidgetListResponseEnvelope"); await Assert.That(plan.Models.Select(static model => model.Name) .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); await Assert.That(plan.Registry.TypeNames - .SequenceEqual(["WidgetInfo"], StringComparer.Ordinal)).IsTrue(); + .SequenceEqual(["WidgetInfo", "WidgetListResponseEnvelope"], StringComparer.Ordinal)).IsTrue(); } [Test] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/EnvelopeDtoEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/EnvelopeDtoEmitterTests.cs new file mode 100644 index 000000000..77f0d5638 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/EnvelopeDtoEmitterTests.cs @@ -0,0 +1,15 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class EnvelopeDtoEmitterTests +{ + [Test] + public async Task Emit_Should_Render_The_Envelope_Dtos() + { + var sources = EnvelopeDtoEmitter.Emit(EmitterPlanFixture.CreateClientPlans()); + + await Verify(EmitterSnapshot.Create(sources)); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/OptionsEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/OptionsEmitterTests.cs new file mode 100644 index 000000000..563ace8fa --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/OptionsEmitterTests.cs @@ -0,0 +1,15 @@ +using OpenCode.Sdk.Tools.Generator.Emission; +using OpenCode.Sdk.Tools.Tests.Support; + +namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; + +public sealed class OptionsEmitterTests +{ + [Test] + public async Task Emit_Should_Render_The_Options_Records() + { + var sources = OptionsEmitter.Emit(EmitterPlanFixture.CreateClientPlans()); + + await Verify(EmitterSnapshot.Create(sources)); + } +} 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 2029cfe94..da09c2b37 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 @@ -7,6 +7,8 @@ using System.Threading; using System.Threading.Tasks; using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -83,14 +85,14 @@ public class OpenCodeClient : IDisposable /// /// Check example ping /// - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'PingResponse' 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 GetPingAsync(OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetPingAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Ping.Get, PingResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Ping.Get, PingResponseAdapter.Instance, requestOptions, cancellationToken); } } @@ -103,6 +105,8 @@ using System.Threading; using System.Threading.Tasks; using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -134,15 +138,29 @@ public class WidgetClient /// Get one widget item /// /// The 'itemID' route value. - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'WidgetItemResponse' envelope. /// The API returned an error status (declared: 400, 404) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task GetItemAsync(string itemId, OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetItemAsync(string itemId, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(itemId); - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetItem(WidgetId, itemId), WidgetItemResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetItem(WidgetId, itemId), WidgetItemResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List the widget items + /// + /// The query options. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetItemListResponse' envelope. + /// The API returned an error status (declared: 404) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListItemsAsync(ItemListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListItems(WidgetId, options), WidgetItemListResponseAdapter.Instance, requestOptions, cancellationToken); } } @@ -155,6 +173,8 @@ using System.Threading; using System.Threading.Tasks; using OpenCode.Sdk.Internal; using OpenCode.Sdk.Internal.ResponseAdapters; +using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk; /// @@ -192,13 +212,41 @@ public class WidgetsClient /// /// List the widget overview /// - /// The per-call options. + /// The per-call options. /// The cancellation token. /// The 'WidgetOverviewResponse' 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 GetOverviewAsync(OpenCodeRequestOptions? options = null, CancellationToken cancellationToken = default) + public virtual Task GetOverviewAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetOverview, WidgetOverviewResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List the widgets + /// + /// The query options. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetListResponse' envelope. + /// The API returned an error status (declared: 400, 500) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListWidgetsAsync(WidgetListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListWidgets(options), WidgetListResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Create one widget + /// + /// The request body; an empty body is sent when omitted. + /// The per-call options. + /// The cancellation token. + /// The 'WidgetCreateResponse' 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 CreateWidgetAsync(WidgetCreateRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.GetOverview, WidgetOverviewResponseAdapter.Instance, options, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Widgets.CreateWidget, request ?? new WidgetCreateRequest(), OpenCodeJsonContext.Default.WidgetCreateRequest, WidgetCreateResponseAdapter.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 new file mode 100644 index 000000000..0a6705ea9 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/EnvelopeDtoEmitterTests.Emit_Should_Render_The_Envelope_Dtos.verified.txt @@ -0,0 +1,69 @@ +// File: Internal/Serialization/WidgetCreateResponseEnvelope.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 'WidgetCreateResponse' through one deserialization pass. +/// +internal sealed record WidgetCreateResponseEnvelope +{ + [JsonPropertyName("data")] + public required ExampleItem Data { get; init; } +} + +// File: Internal/Serialization/WidgetItemListResponseEnvelope.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 'WidgetItemListResponse' through one deserialization pass. +/// +internal sealed record WidgetItemListResponseEnvelope +{ + [JsonPropertyName("data")] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { get; init; } +} + +// File: Internal/Serialization/WidgetItemResponseEnvelope.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 'WidgetItemResponse' through one deserialization pass. +/// +internal sealed record WidgetItemResponseEnvelope +{ + [JsonPropertyName("data")] + public required ExampleItem Data { get; init; } +} + +// File: Internal/Serialization/WidgetListResponseEnvelope.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 'WidgetListResponse' through one deserialization pass. +/// +internal sealed record WidgetListResponseEnvelope +{ + [JsonPropertyName("data")] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { 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 c816a12c7..8f6d0ca97 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 @@ -60,6 +60,136 @@ public sealed record PingResponse : OpenCodeResponse } } +// File: WidgetCreateResponse.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 'POST /api/widget' operation. +/// +public sealed record WidgetCreateResponse : OpenCodeResponse +{ + private readonly ExampleItem? _widget; + /// + /// Initializes a success instance of the 'WidgetCreateResponse' envelope. + /// + public WidgetCreateResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetCreateResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Widget = null!; + } + + /// + /// Gets the Widget payload; guarded on the error path. + /// + public required ExampleItem Widget { get => _widget ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widget."); init => _widget = 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 (_widget is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Widget = ").Append(_widget); + return true; + } +} + +// File: WidgetItemListResponse.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/{widgetID}/item' operation. +/// +public sealed record WidgetItemListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _items; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'WidgetItemListResponse' envelope. + /// + public WidgetItemListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetItemListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Items = null!; + Cursor = null!; + } + + /// + /// Gets the Items payload; guarded on the error path. + /// + public required IReadOnlyList Items { get => _items ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Items."); init => _items = Array.AsReadOnly([..value]); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_items is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Items = ").Append(_items); + return true; + } +} + // File: WidgetItemResponse.cs // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. @@ -122,6 +252,74 @@ public sealed record WidgetItemResponse : OpenCodeResponse } } +// File: WidgetListResponse.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' operation. +/// +public sealed record WidgetListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _widgets; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'WidgetListResponse' envelope. + /// + public WidgetListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal WidgetListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Widgets = null!; + Cursor = null!; + } + + /// + /// Gets the Widgets payload; guarded on the error path. + /// + public required IReadOnlyList Widgets { get => _widgets ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widgets."); init => _widgets = Array.AsReadOnly([..value]); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_widgets is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Widgets = ").Append(_widgets); + return true; + } +} + // File: WidgetOverviewResponse.cs // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt new file mode 100644 index 000000000..d151c8907 --- /dev/null +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt @@ -0,0 +1,37 @@ +// File: ItemListOptions.cs +// 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/widget/{widgetID}/item' query. +/// +public sealed record ItemListOptions : ListOptions +{ +} + +// File: WidgetListOptions.cs +// 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/widget' query. +/// +public sealed record WidgetListOptions +{ + /// + /// Gets the 'limit' query value; the server default applies when unset. + /// + public int? Limit { get; init; } + /// + /// Gets the 'order' query value; the server default applies when unset. + /// + public ListOrder? Order { get; init; } + /// + /// Gets the 'cursor' query value; the server default applies when unset. + /// + public string? Cursor { get; init; } + /// + /// Gets the 'parentID' query value; the server default applies when unset. + /// + public SessionParentFilter? ParentId { get; init; } +} 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 a60d367fb..79b2b0321 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 @@ -15,6 +15,11 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(OpenCodeError))] [JsonSerializable(typeof(UnknownExampleEvent))] [JsonSerializable(typeof(UnknownOpenCodeError))] +[JsonSerializable(typeof(WidgetCreateRequest))] +[JsonSerializable(typeof(WidgetCreateResponseEnvelope))] +[JsonSerializable(typeof(WidgetItemListResponseEnvelope))] +[JsonSerializable(typeof(WidgetItemResponseEnvelope))] +[JsonSerializable(typeof(WidgetListResponseEnvelope))] 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 59583d658..3d464fcf9 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 @@ -39,6 +39,91 @@ internal sealed class PingResponseAdapter : ResponseAdapter } } +// File: Internal/ResponseAdapters/WidgetCreateResponseAdapter.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 'POST /api/widget' responses onto 'WidgetCreateResponse'. +/// +internal sealed class WidgetCreateResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["BadRequestError"]; + private WidgetCreateResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetCreateResponseAdapter Instance { get; } = new WidgetCreateResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetCreateResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => new WidgetCreateResponse + { + Status = status, + Widget = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetCreateResponseEnvelope).Data + }, + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new WidgetCreateResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + _ => new WidgetCreateResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } +} + +// File: Internal/ResponseAdapters/WidgetItemListResponseAdapter.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/{widgetID}/item' responses onto 'WidgetItemListResponse'. +/// +internal sealed class WidgetItemListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status404Tags = ["MissingError"]; + private WidgetItemListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetItemListResponseAdapter Instance { get; } = new WidgetItemListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetItemListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetItemListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 404 => new WidgetItemListResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new WidgetItemListResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetItemListResponse CreateSuccess(int status, WidgetItemListResponseEnvelope envelope) => new WidgetItemListResponse + { + Status = status, + Items = envelope.Data, + Cursor = envelope.Cursor + }; +} + // File: Internal/ResponseAdapters/WidgetItemResponseAdapter.cs // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. @@ -73,7 +158,7 @@ internal sealed class WidgetItemResponseAdapter : ResponseAdapter new WidgetItemResponse { Status = status, - Item = ReadDataPayload(rawBody, OpenCodeJsonContext.Default.ExampleItem) + Item = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetItemResponseEnvelope).Data }, >= 200 and < 300 => throw UndeclaredSuccessFailure(status), 400 => new WidgetItemResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), @@ -82,6 +167,52 @@ internal sealed class WidgetItemResponseAdapter : ResponseAdapter +/// Adapts the 'GET /api/widget' responses onto 'WidgetListResponse'. +/// +internal sealed class WidgetListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["BadRequestError"]; + private static readonly string[] Status500Tags = ["BadRequestError"]; + private WidgetListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static WidgetListResponseAdapter Instance { get; } = new WidgetListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override WidgetListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.WidgetListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new WidgetListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 500 => new WidgetListResponse(status, ReadTolerantError(rawBody, Status500Tags), rawBody), + _ => new WidgetListResponse(status, ReadTolerantError(rawBody, null), rawBody)}; + } + + private static WidgetListResponse CreateSuccess(int status, WidgetListResponseEnvelope envelope) => new WidgetListResponse + { + Status = status, + Widgets = envelope.Data, + Cursor = envelope.Cursor + }; +} + // File: Internal/ResponseAdapters/WidgetOverviewResponseAdapter.cs // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. 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 ec1dd9f4c..c1254cf2a 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 @@ -2,6 +2,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 System; +using OpenCode.Sdk.Internal; namespace OpenCode.Sdk; /// @@ -25,6 +26,10 @@ public static class OpenCodeRoutes /// public static class Widgets { + /// + /// The 'POST /api/widget' route. + /// + public const string CreateWidget = "/api/widget"; /// /// The 'GET /api/widget/{widgetID}/item/{itemID}' route template. /// @@ -56,5 +61,60 @@ public static class OpenCodeRoutes /// The 'GET /api/widget-overview' route. /// public const string GetOverview = "/api/widget-overview"; + /// + /// The 'GET /api/widget/{widgetID}/item' route template. + /// + public const string ListItemsTemplate = "/api/widget/{widgetID}/item"; + /// + /// Builds the '/api/widget/{widgetID}/item' route. + /// + /// The 'widgetID' route value. + /// The query options. + /// The escaped route. + public static string ListItems(string widgetId, ItemListOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + + var path = "/api/widget/" + Uri.EscapeDataString(widgetId) + "/item"; + if (options is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddCount("limit", options.Limit); + query.AddOrder("order", options.Order); + query.AddText("cursor", options.Cursor); + return path + query.Value; + } + + /// + /// The 'GET /api/widget' route template. + /// + public const string ListWidgetsTemplate = "/api/widget"; + /// + /// Builds the '/api/widget' route. + /// + /// The query options. + /// The escaped route. + public static string ListWidgets(WidgetListOptions? options = null) + { + var path = "/api/widget"; + if (options is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddCount("limit", options.Limit); + query.AddOrder("order", options.Order); + query.AddText("cursor", options.Cursor); + query.AddParentFilter("parentID", options.ParentId); + 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 5e4e5df15..4a1c289c7 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -14,6 +14,7 @@ public static EmitPlan Create() CreateVariant("CreatedEvent", "created", "item", "Item", Named("ExampleItem")), CreateVariant("DeletedEvent", "deleted", "id", "ID", Named("string")), CreateError(), + CreateWidgetCreateRequest(), }; var unions = new[] { @@ -40,6 +41,11 @@ public static EmitPlan Create() "OpenCodeError", "UnknownExampleEvent", "UnknownOpenCodeError", + "WidgetCreateRequest", + "WidgetCreateResponseEnvelope", + "WidgetItemListResponseEnvelope", + "WidgetItemResponseEnvelope", + "WidgetListResponseEnvelope", ], }, PendingOperations = [], @@ -70,7 +76,7 @@ public static IReadOnlyList CreateClientPlans() => Role = ClientRole.Handle, SubClients = [], HandleParameter = CreateWidgetParameter(), - Operations = [CreateItemOperation()], + Operations = [CreateItemOperation(), CreateItemListOperation()], }, new ClientPlan { @@ -84,7 +90,7 @@ public static IReadOnlyList CreateClientPlans() => HandleTypeName = "WidgetClient", Parameter = CreateWidgetParameter(), }, - Operations = [CreateOverviewOperation()], + Operations = [CreateOverviewOperation(), CreateWidgetListOperation(), CreateWidgetCreateOperation()], }, ]; @@ -171,6 +177,7 @@ private static OperationPlan CreateItemOperation() => PayloadName = "Item", PayloadTypeName = "ExampleItem", Kind = EnvelopeKind.Data, + EnvelopeDtoTypeName = "WidgetItemResponseEnvelope", }, ErrorMap = new ErrorMapPlan { @@ -205,6 +212,164 @@ private static OperationParameterPlan CreateWidgetParameter() => IsHandleParameter = true, }; + /// A flat options list operation covering every query value kind, a cursor envelope, and a 5xx arm. + private static OperationPlan CreateWidgetListOperation() => + new() + { + MethodName = "ListWidgetsAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget", + RouteContainerName = "Widgets", + RouteMemberName = "ListWidgets", + Parameters = [], + Options = new OperationOptionsPlan + { + TypeName = "WidgetListOptions", + DerivesFromListOptions = false, + Properties = + [ + OptionsProperty("limit", "Limit", QueryValueKind.PositiveCount), + OptionsProperty("order", "Order", QueryValueKind.ListOrder), + OptionsProperty("cursor", "Cursor", QueryValueKind.Text), + OptionsProperty("parentID", "ParentId", QueryValueKind.SessionParentFilter), + ], + }, + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetListResponse", + AdapterTypeName = "WidgetListResponseAdapter", + PayloadName = "Widgets", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.CursorList, + EnvelopeDtoTypeName = "WidgetListResponseEnvelope", + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 400, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + new ErrorStatusPlan + { + StatusCode = 500, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + ], + }, + Summary = "List the widgets", + Description = null, + }; + + /// A handle-scoped list whose options derive from the ListOptions base. + private static OperationPlan CreateItemListOperation() => + new() + { + MethodName = "ListItemsAsync", + HttpMethod = "get", + RouteTemplate = "/api/widget/{widgetID}/item", + RouteContainerName = "Widgets", + RouteMemberName = "ListItems", + Parameters = [CreateWidgetParameter()], + Options = new OperationOptionsPlan + { + TypeName = "ItemListOptions", + DerivesFromListOptions = true, + Properties = + [ + OptionsProperty("limit", "Limit", QueryValueKind.PositiveCount, isInherited: true), + OptionsProperty("order", "Order", QueryValueKind.ListOrder, isInherited: true), + OptionsProperty("cursor", "Cursor", QueryValueKind.Text, isInherited: true), + ], + }, + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetItemListResponse", + AdapterTypeName = "WidgetItemListResponseAdapter", + PayloadName = "Items", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.CursorList, + EnvelopeDtoTypeName = "WidgetItemListResponseEnvelope", + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 404, + Tags = [new ErrorTagPlan { Tag = "MissingError", TypeName = "MissingError", }], + }, + ], + }, + Summary = "List the widget items", + Description = null, + }; + + /// A create operation with an optional all-optional request body. + private static OperationPlan CreateWidgetCreateOperation() => + new() + { + MethodName = "CreateWidgetAsync", + HttpMethod = "post", + RouteTemplate = "/api/widget", + RouteContainerName = "Widgets", + RouteMemberName = "CreateWidget", + Parameters = [], + RequestBody = new RequestBodyPlan + { + TypeName = "WidgetCreateRequest", + ParameterName = "request", + IsOptional = true, + }, + Envelope = new EnvelopePlan + { + ResponseTypeName = "WidgetCreateResponse", + AdapterTypeName = "WidgetCreateResponseAdapter", + PayloadName = "Widget", + PayloadTypeName = "ExampleItem", + Kind = EnvelopeKind.Data, + EnvelopeDtoTypeName = "WidgetCreateResponseEnvelope", + }, + ErrorMap = new ErrorMapPlan + { + Statuses = + [ + new ErrorStatusPlan + { + StatusCode = 400, + Tags = [new ErrorTagPlan { Tag = "BadRequestError", TypeName = "BadRequestError", }], + }, + ], + }, + Summary = "Create one widget", + Description = null, + }; + + private static OptionsPropertyPlan OptionsProperty(string wireName, string propertyName, QueryValueKind kind, + bool isInherited = false) => + new() + { + WireName = wireName, + PropertyName = propertyName, + Kind = kind, + IsInherited = isInherited, + }; + + private static ObjectModelPlan CreateWidgetCreateRequest() => + new() + { + Name = "WidgetCreateRequest", + Namespace = "OpenCode.Sdk.Models", + Description = "Shapes the widget-create request body.", + Properties = + [ + Property("title", "Title", Named("string", isNullable: true), isRequired: false, "Gets the widget title."), + ], + }; + public static EmitPlan CreateModelSnapshot() { var plan = Create(); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs index 375f3d350..06b2a0c1b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnvelopePlan.cs @@ -12,4 +12,10 @@ internal sealed record EnvelopePlan public required string PayloadTypeName { get; init; } public required EnvelopeKind Kind { get; init; } + + /// + /// Gets the internal single-pass deserialization DTO type name for wrapped envelopes, + /// or when the body is the payload itself. + /// + 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 95c650452..a6388f183 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -17,6 +17,7 @@ internal sealed class OperationPlanBinder /// private static readonly string[] ReservedPayloadNames = [ + "Cursor", "Deconstruct", "EqualityContract", "Equals", @@ -419,19 +420,21 @@ 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 = 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, - }, + Kind = kind, + EnvelopeDtoTypeName = kind is EnvelopeKind.Bare ? null : $"{responseTypeName}Envelope", }; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 7439f15f1..8e8dc74c6 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -64,12 +64,25 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera SelectedOperationIds = selection.OperationIds, Models = schemaResult.Models, Unions = schemaResult.Unions, - Registry = schemaResult.Registry, + Registry = ComposeRegistry(schemaResult.Registry, clients), Clients = clients, PendingOperations = pending, }; } + /// Wrapped envelopes deserialize through internal DTOs, which join the serializer registry. + private static RegistryPlan ComposeRegistry(RegistryPlan schemaRegistry, IReadOnlyList clients) + { + var dtoNames = clients + .SelectMany(static client => client.Operations) + .Select(static operation => operation.Envelope.EnvelopeDtoTypeName) + .OfType(); + return new RegistryPlan + { + TypeNames = [.. schemaRegistry.TypeNames.Concat(dtoNames).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)], + }; + } + /// /// 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 diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs index 36ee02ad8..d35787299 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs @@ -69,6 +69,8 @@ private static GeneratedSource EmitClient(ClientPlan client) "System.Threading.Tasks", "OpenCode.Sdk.Internal", "OpenCode.Sdk.Internal.ResponseAdapters", + "OpenCode.Sdk.Internal.Serialization", + "OpenCode.Sdk.Models", ], [declaration]); return EmissionSyntax.CreateSource($"{client.Name}.cs", unit); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs new file mode 100644 index 000000000..e23ec60c9 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs @@ -0,0 +1,82 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +/// +/// Emits the internal single-pass deserialization DTO of every wrapped envelope; required +/// properties make a missing wire member a deserialization failure, so the adapters' +/// protocol walls hold without a second parse. +/// +internal static class EnvelopeDtoEmitter +{ + public static IReadOnlyList Emit(IReadOnlyList clients) + { + ArgumentNullException.ThrowIfNull(clients); + + return Array.AsReadOnly( + [ + .. clients + .SelectMany(static client => client.Operations) + .Where(static operation => operation.Envelope.EnvelopeDtoTypeName is not null) + .OrderBy(static operation => operation.Envelope.EnvelopeDtoTypeName, StringComparer.Ordinal) + .Select(static operation => EmitDto(operation.Envelope)), + ]); + } + + private static GeneratedSource EmitDto(EnvelopePlan envelope) + { + var members = new List + { + EmitProperty("data", "Data", EmitDataType(envelope)), + }; + if (envelope.Kind is EnvelopeKind.CursorList) + { + members.Add(EmitProperty("cursor", "Cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); + } + + var declaration = SyntaxFactory.RecordDeclaration( + SyntaxFactory.Token(SyntaxKind.RecordKeyword), + envelope.EnvelopeDtoTypeName!) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.InternalKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithMembers(SyntaxFactory.List(members)) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Carries the wire body of '{envelope.ResponseTypeName}' through one deserialization pass.")); + var unit = EmissionSyntax.CompilationUnit( + "OpenCode.Sdk.Internal.Serialization", + ["System.Text.Json.Serialization", "OpenCode.Sdk.Models"], + [declaration]); + return EmissionSyntax.CreateSource($"Internal/Serialization/{envelope.EnvelopeDtoTypeName}.cs", unit); + } + + private static TypeSyntax EmitDataType(EnvelopePlan envelope) => envelope.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."), + }; + + private static PropertyDeclarationSyntax EmitProperty(string wireName, string name, TypeSyntax type) => + SyntaxFactory.PropertyDeclaration(type, name) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.RequiredKeyword))) + .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.List( + [ + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + ]))) + .AddAttributeLists(EmissionSyntax.Attribute( + "JsonPropertyName", + SyntaxFactory.AttributeArgument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(wireName))))); +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index 62d4d7534..6f8d7d5ba 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -25,6 +25,25 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) { var envelope = operation.Envelope; var fieldName = $"_{CSharpNamePolicy.ToCamelCase(envelope.PayloadName)}"; + var payloadType = PayloadType(envelope); + var members = new List + { + EmitBackingField(fieldName, payloadType), + }; + if (envelope.Kind is EnvelopeKind.CursorList) + { + members.Add(EmitBackingField("_cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); + } + + members.Add(EmitSuccessConstructor(envelope.ResponseTypeName)); + members.Add(EmitErrorConstructor(envelope)); + members.Add(EmitPayloadProperty(envelope, payloadType, fieldName)); + if (envelope.Kind is EnvelopeKind.CursorList) + { + 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), @@ -33,14 +52,7 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("OpenCodeResponse"))))) .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) - .WithMembers(SyntaxFactory.List( - [ - EmitBackingField(fieldName, envelope.PayloadTypeName), - EmitSuccessConstructor(envelope.ResponseTypeName), - EmitErrorConstructor(envelope), - EmitPayloadProperty(envelope, fieldName), - EmitPrintMembers(envelope.PayloadName, fieldName), - ])) + .WithMembers(SyntaxFactory.List(members)) .WithLeadingTrivia(EmissionSyntax.Documentation( $"Represents the response of the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' operation.")); var unit = EmissionSyntax.CompilationUnit( @@ -50,9 +62,13 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) return EmissionSyntax.CreateSource($"{envelope.ResponseTypeName}.cs", unit); } - private static FieldDeclarationSyntax EmitBackingField(string fieldName, string payloadTypeName) => + private static TypeSyntax PayloadType(EnvelopePlan envelope) => envelope.Kind is EnvelopeKind.CursorList + ? TypeSyntaxEmitter.Generic("IReadOnlyList", TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)) + : TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName); + + private static FieldDeclarationSyntax EmitBackingField(string fieldName, TypeSyntax payloadType) => SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration( - SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(payloadTypeName)), + SyntaxFactory.NullableType(payloadType), SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator(fieldName)))) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PrivateKeyword), @@ -71,7 +87,7 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en // The payload assignment is the SDK's single null-forgiveness: the guarded getter // makes the null unobservable. - var assignments = new StatementSyntax[] + var assignments = new List { Assign("Status", SyntaxFactory.IdentifierName("status")), Assign("IsError", SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)), @@ -81,6 +97,12 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en SyntaxKind.SuppressNullableWarningExpression, SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), }; + if (envelope.Kind is EnvelopeKind.CursorList) + { + assignments.Add(Assign("Cursor", SyntaxFactory.PostfixUnaryExpression( + SyntaxKind.SuppressNullableWarningExpression, + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))); + } return SyntaxFactory.ConstructorDeclaration(responseTypeName) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.InternalKeyword))) .AddAttributeLists(EmissionSyntax.Attribute("SetsRequiredMembers")) @@ -98,9 +120,39 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en "Initializes an error-path instance; the payload stays unset behind its guard.")); } - private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelope, string fieldName) + private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelope, TypeSyntax payloadType, string fieldName) + { + ExpressionSyntax initValue = envelope.Kind is EnvelopeKind.CursorList + ? DefensiveListCopy() + : SyntaxFactory.IdentifierName("value"); + return EmitGuardedProperty( + payloadType, + envelope.PayloadName, + fieldName, + initValue, + $"Gets the {envelope.PayloadName} payload; guarded on the error path."); + } + + private static PropertyDeclarationSyntax EmitCursorProperty() => + EmitGuardedProperty( + TypeSyntaxEmitter.EmitNamed("ListCursor"), + "Cursor", + "_cursor", + SyntaxFactory.IdentifierName("value"), + "Gets the page cursor; guarded on the error path."); + + /// List payloads defensively copy on init so the envelope stays immutable. + private static InvocationExpressionSyntax DefensiveListCopy() => + EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Array"), "AsReadOnly"), + SyntaxFactory.Argument(SyntaxFactory.CollectionExpression( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SpreadElement(SyntaxFactory.IdentifierName("value")))))); + + private static PropertyDeclarationSyntax EmitGuardedProperty(TypeSyntax propertyType, string propertyName, + string fieldName, ExpressionSyntax initValue, string documentation) { - var guardMessage = $"The response is an error; check IsError before accessing {envelope.PayloadName}."; + var guardMessage = $"The response is an error; check IsError before accessing {propertyName}."; var getter = SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.BinaryExpression( SyntaxKind.CoalesceExpression, @@ -116,15 +168,14 @@ private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelo .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName(fieldName), - SyntaxFactory.IdentifierName("value")))) + initValue))) .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); - return SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName), envelope.PayloadName) + return SyntaxFactory.PropertyDeclaration(propertyType, propertyName) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.RequiredKeyword))) .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.List([getter, setter]))) - .WithLeadingTrivia(EmissionSyntax.Documentation( - $"Gets the {envelope.PayloadName} payload; guarded on the error path.")); + .WithLeadingTrivia(EmissionSyntax.Documentation(documentation)); } private static MethodDeclarationSyntax EmitPrintMembers(string payloadName, string fieldName) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs index 976cb45d5..15df2b0e6 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs @@ -23,6 +23,11 @@ public static MethodDeclarationSyntax Emit(OperationPlan operation) statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(parameter.Name)); } + if (operation.RequestBody is { IsOptional: false } requiredBody) + { + statements.AddRange(EmissionSyntax.ArgumentNullGuard(requiredBody.ParameterName)); + } + statements.Add(SyntaxFactory.ReturnStatement(EmitDelegation(operation))); return SyntaxFactory.MethodDeclaration( TypeSyntaxEmitter.Generic("Task", TypeSyntaxEmitter.EmitNamed(operation.Envelope.ResponseTypeName)), @@ -30,12 +35,13 @@ public static MethodDeclarationSyntax Emit(OperationPlan operation) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.VirtualKeyword))) - .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(EmitParameters(methodParameters)))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(EmitParameters(operation, methodParameters)))) .WithBody(SyntaxFactory.Block(statements)) .WithLeadingTrivia(EmitDocumentation(operation, methodParameters)); } - private static IEnumerable EmitParameters(IReadOnlyList methodParameters) + private static IEnumerable EmitParameters(OperationPlan operation, + IReadOnlyList methodParameters) { foreach (var parameter in methodParameters) { @@ -43,7 +49,23 @@ private static IEnumerable EmitParameters(IReadOnlyList EmitParameters(IReadOnlyList - EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Pipeline"), "ExecuteAsync"), + private static InvocationExpressionSyntax EmitDelegation(OperationPlan operation) + { + var arguments = new List + { SyntaxFactory.Argument(EmissionSyntax.MemberAccess( SyntaxFactory.IdentifierName("HttpMethod"), CSharpNamePolicy.ToPascalCase(operation.HttpMethod))), SyntaxFactory.Argument(EmitRoute(operation)), - SyntaxFactory.Argument(EmissionSyntax.MemberAccess( - SyntaxFactory.IdentifierName(operation.Envelope.AdapterTypeName), - "Instance")), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options")), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("cancellationToken"))); + }; + if (operation.RequestBody is not null) + { + arguments.Add(SyntaxFactory.Argument(EmitBodyArgument(operation.RequestBody))); + arguments.Add(SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), + operation.RequestBody.TypeName))); + } + + arguments.Add(SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName(operation.Envelope.AdapterTypeName), + "Instance"))); + arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("requestOptions"))); + arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("cancellationToken"))); + return EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Pipeline"), "ExecuteAsync"), + [.. arguments]); + } + + /// An omitted optional body still sends the empty JSON object the wire requires. + private static ExpressionSyntax EmitBodyArgument(RequestBodyPlan body) + { + var parameter = SyntaxFactory.IdentifierName(body.ParameterName); + if (!body.IsOptional) + { + return parameter; + } + + return SyntaxFactory.BinaryExpression( + SyntaxKind.CoalesceExpression, + parameter, + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(body.TypeName)) + .WithArgumentList(SyntaxFactory.ArgumentList())); + } /// Route arguments come from the method for ordinary parameters and from the bound handle state otherwise. private static ExpressionSyntax EmitRoute(OperationPlan operation) @@ -72,17 +124,21 @@ private static ExpressionSyntax EmitRoute(OperationPlan operation) SyntaxFactory.IdentifierName("OpenCodeRoutes"), operation.RouteContainerName), operation.RouteMemberName); - if (operation.Parameters.Count is 0) + if (operation.Parameters.Count is 0 && operation.Options is null) { return member; } - return EmissionSyntax.Invocation(member, - [ - .. operation.Parameters.Select(static parameter => SyntaxFactory.Argument(parameter.IsHandleParameter - ? SyntaxFactory.IdentifierName(CSharpNamePolicy.ToPascalCase(parameter.Name)) - : SyntaxFactory.IdentifierName(parameter.Name))), - ]); + var arguments = new List(); + arguments.AddRange(operation.Parameters.Select(static parameter => SyntaxFactory.Argument(parameter.IsHandleParameter + ? SyntaxFactory.IdentifierName(CSharpNamePolicy.ToPascalCase(parameter.Name)) + : SyntaxFactory.IdentifierName(parameter.Name)))); + if (operation.Options is not null) + { + arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options"))); + } + + return EmissionSyntax.Invocation(member, [.. arguments]); } private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, IReadOnlyList methodParameters) @@ -90,7 +146,21 @@ private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, IRead var parameters = new List(); parameters.AddRange(methodParameters.Select(static parameter => new DocumentedParameter(parameter.Name, $"The '{parameter.WireName}' route value."))); - parameters.Add(new DocumentedParameter("options", "The per-call options.")); + if (operation.RequestBody is not null) + { + parameters.Add(new DocumentedParameter( + operation.RequestBody.ParameterName, + operation.RequestBody.IsOptional + ? "The request body; an empty body is sent when omitted." + : "The request body.")); + } + + if (operation.Options is not null) + { + parameters.Add(new DocumentedParameter("options", "The query options.")); + } + + parameters.Add(new DocumentedParameter("requestOptions", "The per-call options.")); parameters.Add(new DocumentedParameter("cancellationToken", "The cancellation token.")); var exceptions = new List(); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OptionsEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/OptionsEmitter.cs new file mode 100644 index 000000000..62d14c53c --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/OptionsEmitter.cs @@ -0,0 +1,79 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Emission; + +/// +/// Emits one public options record per query-carrying operation: derived records inherit the +/// ListOptions trio verbatim, flat records declare every bound query property. +/// +internal static class OptionsEmitter +{ + public static IReadOnlyList Emit(IReadOnlyList clients) + { + ArgumentNullException.ThrowIfNull(clients); + + return Array.AsReadOnly( + [ + .. clients + .SelectMany(static client => client.Operations) + .Where(static operation => operation.Options is not null) + .OrderBy(static operation => operation.Options!.TypeName, StringComparer.Ordinal) + .Select(static operation => EmitOptions(operation)), + ]); + } + + private static GeneratedSource EmitOptions(OperationPlan operation) + { + var options = operation.Options!; + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), options.TypeName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) + .WithMembers(SyntaxFactory.List( + [ + .. options.Properties + .Where(static property => !property.IsInherited) + .Select(static property => EmitProperty(property)), + ])) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Shapes the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' query.")); + if (options.DerivesFromListOptions) + { + declaration = declaration.WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("ListOptions"))))); + } + + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", [], [declaration]); + return EmissionSyntax.CreateSource($"{options.TypeName}.cs", unit); + } + + private static PropertyDeclarationSyntax EmitProperty(OptionsPropertyPlan property) + { + var accessors = SyntaxFactory.AccessorList(SyntaxFactory.List( + [ + SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + ])); + return SyntaxFactory.PropertyDeclaration(EmitPropertyType(property.Kind), property.PropertyName) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) + .WithAccessorList(accessors) + .WithLeadingTrivia(EmissionSyntax.Documentation( + $"Gets the '{property.WireName}' query value; the server default applies when unset.")); + } + + private static NullableTypeSyntax EmitPropertyType(QueryValueKind kind) => kind switch + { + QueryValueKind.Text => SyntaxFactory.NullableType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword))), + QueryValueKind.PositiveCount => SyntaxFactory.NullableType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), + QueryValueKind.ListOrder => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("ListOrder")), + QueryValueKind.SessionParentFilter => SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("SessionParentFilter")), + _ => throw new InvalidOperationException($"Query value kind '{kind}' has no property type."), + }; +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs index 18f299a0a..8e279258a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs @@ -31,6 +31,10 @@ private static GeneratedSource EmitAdapter(OperationPlan operation) .WithBody(SyntaxFactory.Block())); members.Add(EmitInstance(envelope.AdapterTypeName)); members.Add(EmitAdapt(operation)); + if (envelope.Kind is EnvelopeKind.CursorList) + { + members.Add(EmitCursorSuccessHelper(envelope)); + } var declaration = SyntaxFactory.ClassDeclaration(envelope.AdapterTypeName) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.InternalKeyword), @@ -127,22 +131,31 @@ private static MethodDeclarationSyntax EmitAdapt(OperationPlan operation) .WithLeadingTrivia(EmissionSyntax.Documentation("Maps one buffered response onto the typed envelope.")); } - private static ObjectCreationExpressionSyntax EmitSuccessCreation(EnvelopePlan envelope) + /// + /// Every success path is one deserialization pass: bare bodies read the payload type, + /// wrapped bodies read their internal envelope DTO and project its members. + /// + private static ExpressionSyntax EmitSuccessCreation(EnvelopePlan envelope) { - var reader = envelope.Kind switch - { - EnvelopeKind.Bare => "ReadBarePayload", - EnvelopeKind.Data => "ReadDataPayload", - EnvelopeKind.CursorList or _ => - throw new InvalidOperationException($"Envelope kind '{envelope.Kind}' has no adapter reader yet."), - }; - var payload = EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName(reader), + var read = EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("ReadBarePayload"), SyntaxFactory.Argument(SyntaxFactory.IdentifierName("rawBody")), SyntaxFactory.Argument(EmissionSyntax.MemberAccess( EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"), - envelope.PayloadTypeName))); - return SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) + envelope.EnvelopeDtoTypeName ?? envelope.PayloadTypeName))); + return envelope.Kind switch + { + EnvelopeKind.Bare => EmitSuccessInitializer(envelope, read), + EnvelopeKind.Data => EmitSuccessInitializer(envelope, EmissionSyntax.MemberAccess(read, "Data")), + EnvelopeKind.CursorList or _ => EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("CreateSuccess"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("status")), + SyntaxFactory.Argument(read)), + }; + } + + private static ObjectCreationExpressionSyntax EmitSuccessInitializer(EnvelopePlan envelope, ExpressionSyntax payload) => + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) .WithInitializer(SyntaxFactory.InitializerExpression( SyntaxKind.ObjectInitializerExpression, SyntaxFactory.SeparatedList( @@ -156,7 +169,40 @@ private static ObjectCreationExpressionSyntax EmitSuccessCreation(EnvelopePlan e SyntaxFactory.IdentifierName(envelope.PayloadName), 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") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PrivateKeyword), + SyntaxFactory.Token(SyntaxKind.StaticKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("status")) + .WithType(SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword))), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("envelope")) + .WithType(TypeSyntaxEmitter.EmitNamed(envelope.EnvelopeDtoTypeName!)), + ]))) + .WithExpressionBody(SyntaxFactory.ArrowExpressionClause( + SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) + .WithInitializer(SyntaxFactory.InitializerExpression( + SyntaxKind.ObjectInitializerExpression, + SyntaxFactory.SeparatedList( + [ + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Status"), + SyntaxFactory.IdentifierName("status")), + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(envelope.PayloadName), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Data")), + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Cursor"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("envelope"), "Cursor")), + ]))))) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)); private static ObjectCreationExpressionSyntax EmitErrorCreation(EnvelopePlan envelope, ExpressionSyntax allowedTags) => SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(envelope.ResponseTypeName)) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs index 7184756a1..04de9aa74 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -23,9 +23,17 @@ public static GeneratedSource Emit(IReadOnlyList clients) SyntaxFactory.Token(SyntaxKind.StaticKeyword))) .WithMembers(SyntaxFactory.List(containers)) .WithLeadingTrivia(EmissionSyntax.Documentation("Defines the wire route of every generated operation.")); - var usings = operations.Any(static operation => operation.Parameters.Count > 0) - ? new[] { "System" } - : []; + var usings = new List(); + if (operations.Any(static operation => operation.Parameters.Count > 0)) + { + usings.Add("System"); + } + + if (operations.Any(static operation => operation.Options is not null)) + { + usings.Add("OpenCode.Sdk.Internal"); + } + var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", usings, [declaration]); return EmissionSyntax.CreateSource("OpenCodeRoutes.cs", unit); } @@ -35,7 +43,7 @@ private static ClassDeclarationSyntax EmitContainer(IGrouping(); foreach (var operation in container.OrderBy(static operation => operation.RouteMemberName, StringComparer.Ordinal)) { - if (operation.Parameters.Count is 0) + if (operation.Parameters.Count is 0 && operation.Options is null) { members.Add(EmitConst( operation.RouteMemberName, @@ -79,11 +87,26 @@ private static MethodDeclarationSyntax EmitBuilder(OperationPlan operation) statements.AddRange(EmitRouteValueGuard(parameter.Name)); } - statements.Add(SyntaxFactory.ReturnStatement(EmitConcatenation(operation))); + if (operation.Options is null) + { + statements.Add(SyntaxFactory.ReturnStatement(EmitConcatenation(operation))); + } + else + { + statements.AddRange(EmitQueryComposition(operation)); + } + + var parameters = new List(); + parameters.AddRange(operation.Parameters.Select(static parameter => + new DocumentedParameter(parameter.Name, $"The '{parameter.WireName}' route value."))); + if (operation.Options is not null) + { + parameters.Add(new DocumentedParameter("options", "The query options.")); + } + var documentation = EmissionSyntax.MemberDocumentation( $"Builds the '{operation.RouteTemplate}' route.", - [.. operation.Parameters.Select(static parameter => - new DocumentedParameter(parameter.Name, $"The '{parameter.WireName}' route value."))], + parameters, "The escaped route."); return SyntaxFactory.MethodDeclaration( SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)), @@ -91,13 +114,70 @@ [.. operation.Parameters.Select(static parameter => .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.StaticKeyword))) - .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(operation.Parameters.Select( - static parameter => SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameter.Name)) - .WithType(TypeSyntaxEmitter.EmitNamed(parameter.TypeName)))))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(EmitBuilderParameters(operation)))) .WithBody(SyntaxFactory.Block(statements)) .WithLeadingTrivia(documentation); } + private static IEnumerable EmitBuilderParameters(OperationPlan operation) + { + foreach (var parameter in operation.Parameters) + { + yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameter.Name)) + .WithType(TypeSyntaxEmitter.EmitNamed(parameter.TypeName)); + } + + if (operation.Options is not null) + { + yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")) + .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.Options.TypeName))) + .WithDefault(SyntaxFactory.EqualsValueClause(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))); + } + } + + /// Unset options short-circuit to the bare path; set ones append the composed query suffix. + private static IEnumerable EmitQueryComposition(OperationPlan operation) + { + yield return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration( + SyntaxFactory.IdentifierName("var"), + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("path") + .WithInitializer(SyntaxFactory.EqualsValueClause(EmitConcatenation(operation)))))); + yield return SyntaxFactory.IfStatement( + SyntaxFactory.IsPatternExpression( + SyntaxFactory.IdentifierName("options"), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + SyntaxFactory.Block(SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName("path")))); + 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.Options!.Properties) + { + yield return SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), QueryAddMethod(property.Kind)), + SyntaxFactory.Argument(StringLiteral(property.WireName)), + SyntaxFactory.Argument(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("options"), + property.PropertyName)))); + } + + yield return SyntaxFactory.ReturnStatement(SyntaxFactory.BinaryExpression( + SyntaxKind.AddExpression, + SyntaxFactory.IdentifierName("path"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), "Value"))); + } + + private static string QueryAddMethod(QueryValueKind kind) => kind switch + { + QueryValueKind.Text => "AddText", + QueryValueKind.PositiveCount => "AddCount", + QueryValueKind.ListOrder => "AddOrder", + QueryValueKind.SessionParentFilter => "AddParentFilter", + _ => throw new InvalidOperationException($"Query value kind '{kind}' has no query-builder method."), + }; + /// /// Guards one route value: null/empty/whitespace is refused, and so are the dot segments /// Uri canonicalization would silently rewrite into a different request path. diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs index b2e1c8fea..bc4e20989 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs @@ -15,6 +15,8 @@ public static IReadOnlyList Emit(EmitPlan plan) if (plan.Clients.Count > 0) { sources.Add(RoutesEmitter.Emit(plan.Clients)); + sources.AddRange(OptionsEmitter.Emit(plan.Clients)); + sources.AddRange(EnvelopeDtoEmitter.Emit(plan.Clients)); sources.AddRange(EnvelopeEmitter.Emit(plan.Clients)); sources.AddRange(ResponseAdapterEmitter.Emit(plan.Clients)); sources.AddRange(ClientEmitter.Emit(plan.Clients)); From 70c5a15b1d024b858730768766af5b7f550f2bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 03:08:04 +0300 Subject: [PATCH 12/47] feat(sdk): generate the first breadth batch surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile selects v2.session.list/get/create and v2.message.list; the message wire group joins the Sessions client family and the InvalidRequestError1 duplicate collapses through its curated alias. The committed surface gains ListSessionsAsync, GetSessionAsync, CreateSessionAsync, and ListMessagesAsync with their options records, request model, cursor-list envelopes, single-pass DTOs, query-composing routes, and the Session.Info model closure (fork boundary union with its unknown carrier, revert, file diff, time/fork nests) plus the InvalidCursorError and UnknownError types — the first 5xx arm included. Pinned binder expectations and the PublicApi baseline reviewed alongside; packing stays blocked behind the 114 pending operations. --- src/OpenCode.Sdk/.generated-manifest.json | 28 +++ src/OpenCode.Sdk/.generation-incomplete | 12 +- .../MessageListResponseAdapter.cs | 48 ++++ .../SessionCreateResponseAdapter.cs | 41 ++++ .../SessionListResponseAdapter.cs | 44 ++++ .../SessionResponseAdapter.cs | 43 ++++ .../MessageListResponseEnvelope.cs | 17 ++ .../OpenCodeErrorJsonConverter.cs | 4 +- .../Serialization/OpenCodeJsonContext.cs | 17 ++ .../SessionCreateResponseEnvelope.cs | 14 ++ .../SessionForkBoundaryJsonConverter.cs | 73 ++++++ .../SessionListResponseEnvelope.cs | 17 ++ .../Serialization/SessionResponseEnvelope.cs | 14 ++ src/OpenCode.Sdk/MessageListOptions.cs | 9 + src/OpenCode.Sdk/MessageListResponse.cs | 65 ++++++ src/OpenCode.Sdk/Models/FileDiffInfo.cs | 40 ++++ src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs | 27 +++ src/OpenCode.Sdk/Models/InvalidCursorError.cs | 22 ++ .../Models/SessionCreateRequest.cs | 45 ++++ .../Models/SessionForkBoundary.cs | 18 ++ .../Models/SessionForkBoundaryBefore.cs | 22 ++ .../Models/SessionForkBoundaryThrough.cs | 22 ++ src/OpenCode.Sdk/Models/SessionInfo.cs | 95 ++++++++ src/OpenCode.Sdk/Models/SessionInfoFork.cs | 22 ++ src/OpenCode.Sdk/Models/SessionInfoTime.cs | 29 +++ src/OpenCode.Sdk/Models/SessionRevert.cs | 37 +++ src/OpenCode.Sdk/Models/UnknownError.cs | 29 +++ .../Models/UnknownSessionForkBoundary.cs | 33 +++ src/OpenCode.Sdk/OpenCodeRoutes.cs | 87 +++++++ src/OpenCode.Sdk/SessionClient.cs | 27 +++ src/OpenCode.Sdk/SessionCreateResponse.cs | 59 +++++ src/OpenCode.Sdk/SessionListOptions.cs | 45 ++++ src/OpenCode.Sdk/SessionListResponse.cs | 65 ++++++ src/OpenCode.Sdk/SessionResponse.cs | 59 +++++ src/OpenCode.Sdk/SessionsClient.cs | 31 +++ .../Snapshots/PublicApi.verified.txt | 217 ++++++++++++++++++ .../Binding/OperationPlanBinderTests.cs | 44 +++- .../Generator/Binding/SpecBinderTests.cs | 2 + tools/curation.json | 14 +- tools/generation-profile.txt | 4 + 40 files changed, 1529 insertions(+), 12 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/MessageListResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/SessionCreateResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/ResponseAdapters/SessionResponseAdapter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionCreateResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionForkBoundaryJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/SessionResponseEnvelope.cs create mode 100644 src/OpenCode.Sdk/MessageListOptions.cs create mode 100644 src/OpenCode.Sdk/MessageListResponse.cs create mode 100644 src/OpenCode.Sdk/Models/FileDiffInfo.cs create mode 100644 src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs create mode 100644 src/OpenCode.Sdk/Models/InvalidCursorError.cs create mode 100644 src/OpenCode.Sdk/Models/SessionCreateRequest.cs create mode 100644 src/OpenCode.Sdk/Models/SessionForkBoundary.cs create mode 100644 src/OpenCode.Sdk/Models/SessionForkBoundaryBefore.cs create mode 100644 src/OpenCode.Sdk/Models/SessionForkBoundaryThrough.cs create mode 100644 src/OpenCode.Sdk/Models/SessionInfo.cs create mode 100644 src/OpenCode.Sdk/Models/SessionInfoFork.cs create mode 100644 src/OpenCode.Sdk/Models/SessionInfoTime.cs create mode 100644 src/OpenCode.Sdk/Models/SessionRevert.cs create mode 100644 src/OpenCode.Sdk/Models/UnknownError.cs create mode 100644 src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs create mode 100644 src/OpenCode.Sdk/SessionCreateResponse.cs create mode 100644 src/OpenCode.Sdk/SessionListOptions.cs create mode 100644 src/OpenCode.Sdk/SessionListResponse.cs create mode 100644 src/OpenCode.Sdk/SessionResponse.cs diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index 3850e26fa..dbe9761fd 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -2,17 +2,31 @@ "files": [ "HealthResponse.cs", "Internal/ResponseAdapters/HealthResponseAdapter.cs", + "Internal/ResponseAdapters/MessageListResponseAdapter.cs", + "Internal/ResponseAdapters/SessionCreateResponseAdapter.cs", + "Internal/ResponseAdapters/SessionListResponseAdapter.cs", "Internal/ResponseAdapters/SessionMessageResponseAdapter.cs", + "Internal/ResponseAdapters/SessionResponseAdapter.cs", + "Internal/Serialization/MessageListResponseEnvelope.cs", "Internal/Serialization/OpenCodeErrorJsonConverter.cs", "Internal/Serialization/OpenCodeJsonContext.cs", "Internal/Serialization/OptionalCollectionInput.cs", "Internal/Serialization/PromptFileSourceJsonConverter.cs", + "Internal/Serialization/SessionCreateResponseEnvelope.cs", + "Internal/Serialization/SessionForkBoundaryJsonConverter.cs", + "Internal/Serialization/SessionListResponseEnvelope.cs", "Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs", "Internal/Serialization/SessionMessageCompactionJsonConverter.cs", "Internal/Serialization/SessionMessageInfoJsonConverter.cs", "Internal/Serialization/SessionMessageResponseEnvelope.cs", "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", + "Internal/Serialization/SessionResponseEnvelope.cs", "Internal/Serialization/ToolContentJsonConverter.cs", + "MessageListOptions.cs", + "MessageListResponse.cs", + "Models/FileDiffInfo.cs", + "Models/FileDiffInfoStatus.cs", + "Models/InvalidCursorError.cs", "Models/InvalidRequestError.cs", "Models/LocationRef.cs", "Models/MessageNotFoundError.cs", @@ -26,6 +40,13 @@ "Models/PromptMention.cs", "Models/PromptSkillAttachment.cs", "Models/ServiceHealth.cs", + "Models/SessionCreateRequest.cs", + "Models/SessionForkBoundary.cs", + "Models/SessionForkBoundaryBefore.cs", + "Models/SessionForkBoundaryThrough.cs", + "Models/SessionInfo.cs", + "Models/SessionInfoFork.cs", + "Models/SessionInfoTime.cs", "Models/SessionMessageAgentSelected.cs", "Models/SessionMessageAgentSelectedTime.cs", "Models/SessionMessageAssistant.cs", @@ -73,6 +94,7 @@ "Models/SessionMessageUser.cs", "Models/SessionMessageUserTime.cs", "Models/SessionNotFoundError.cs", + "Models/SessionRevert.cs", "Models/SessionStructuredError.cs", "Models/TokenUsageInfo.cs", "Models/TokenUsageInfoCache.cs", @@ -80,8 +102,10 @@ "Models/ToolFileContent.cs", "Models/ToolTextContent.cs", "Models/UnauthorizedError.cs", + "Models/UnknownError.cs", "Models/UnknownOpenCodeError.cs", "Models/UnknownPromptFileSource.cs", + "Models/UnknownSessionForkBoundary.cs", "Models/UnknownSessionMessageAssistantContent.cs", "Models/UnknownSessionMessageCompaction.cs", "Models/UnknownSessionMessageInfo.cs", @@ -90,7 +114,11 @@ "OpenCodeClient.cs", "OpenCodeRoutes.cs", "SessionClient.cs", + "SessionCreateResponse.cs", + "SessionListOptions.cs", + "SessionListResponse.cs", "SessionMessageResponse.cs", + "SessionResponse.cs", "SessionsClient.cs" ] } diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete index a97248a44..a10db037e 100644 --- a/src/OpenCode.Sdk/.generation-incomplete +++ b/src/OpenCode.Sdk/.generation-incomplete @@ -1,8 +1,12 @@ Generation is incomplete; packages must not be published. -Selected operations: 2 -Pending operations: 118 +Selected operations: 6 +Pending operations: 114 Selected: - v2.health.get +- v2.message.list +- v2.session.create +- v2.session.get +- v2.session.list - v2.session.message Pending: - v2.agent.get @@ -39,7 +43,6 @@ Pending: - v2.mcp.list - v2.mcp.remove - v2.mcp.resource.catalog -- v2.message.list - v2.model.default - v2.model.list - v2.permission.request.list @@ -69,7 +72,6 @@ Pending: - v2.session.command - v2.session.compact - v2.session.context -- v2.session.create - v2.session.export - v2.session.fork - v2.session.form.cancel @@ -79,7 +81,6 @@ Pending: - v2.session.form.reply - v2.session.form.state - v2.session.generate -- v2.session.get - v2.session.import - v2.session.inbox.cancel - v2.session.inbox.list @@ -89,7 +90,6 @@ Pending: - v2.session.instructions.entry.put - v2.session.instructions.entry.remove - v2.session.interrupt -- v2.session.list - v2.session.log - v2.session.move - v2.session.permission.create diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/MessageListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/MessageListResponseAdapter.cs new file mode 100644 index 000000000..65a69230c --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/MessageListResponseAdapter.cs @@ -0,0 +1,48 @@ +// 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/session/{sessionID}/message' responses onto 'MessageListResponse'. +/// +internal sealed class MessageListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidCursorError", "InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private static readonly string[] Status500Tags = ["UnknownError"]; + private MessageListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static MessageListResponseAdapter Instance { get; } = new MessageListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override MessageListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.MessageListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new MessageListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new MessageListResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new MessageListResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + 500 => new MessageListResponse(status, ReadTolerantError(rawBody, Status500Tags), rawBody), + _ => new MessageListResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static MessageListResponse CreateSuccess(int status, MessageListResponseEnvelope envelope) => new() + { + Status = status, + Messages = envelope.Data, + Cursor = envelope.Cursor + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionCreateResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionCreateResponseAdapter.cs new file mode 100644 index 000000000..b930383b7 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionCreateResponseAdapter.cs @@ -0,0 +1,41 @@ +// 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/session' responses onto 'SessionCreateResponse'. +/// +internal sealed class SessionCreateResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private SessionCreateResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionCreateResponseAdapter Instance { get; } = new SessionCreateResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionCreateResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => new SessionCreateResponse + { + Status = status, + Session = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionCreateResponseEnvelope).Data + }, + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionCreateResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionCreateResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new SessionCreateResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs new file mode 100644 index 000000000..0b6adc9ef --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.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/session' responses onto 'SessionListResponse'. +/// +internal sealed class SessionListResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidCursorError", "InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private SessionListResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionListResponseAdapter Instance { get; } = new SessionListResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionListResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => CreateSuccess(status, ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionListResponseEnvelope)), + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionListResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionListResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + _ => new SessionListResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } + + private static SessionListResponse CreateSuccess(int status, SessionListResponseEnvelope envelope) => new() + { + Status = status, + Sessions = envelope.Data, + Cursor = envelope.Cursor + }; +} diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionResponseAdapter.cs new file mode 100644 index 000000000..c8ed3950e --- /dev/null +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionResponseAdapter.cs @@ -0,0 +1,43 @@ +// 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/session/{sessionID}' responses onto 'SessionResponse'. +/// +internal sealed class SessionResponseAdapter : ResponseAdapter +{ + private static readonly string[] Status400Tags = ["InvalidRequestError"]; + private static readonly string[] Status401Tags = ["UnauthorizedError"]; + private static readonly string[] Status404Tags = ["SessionNotFoundError"]; + private SessionResponseAdapter() + { + } + + /// + /// Gets the shared adapter instance. + /// + public static SessionResponseAdapter Instance { get; } = new SessionResponseAdapter(); + + /// + /// Maps one buffered response onto the typed envelope. + /// + public override SessionResponse Adapt(int status, string rawBody) + { + ArgumentNullException.ThrowIfNull(rawBody); + return status switch + { + 200 => new SessionResponse + { + Status = status, + Session = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionResponseEnvelope).Data + }, + >= 200 and < 300 => throw UndeclaredSuccessFailure(status), + 400 => new SessionResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), + 401 => new SessionResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), + 404 => new SessionResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), + _ => new SessionResponse(status, ReadTolerantError(rawBody, null), rawBody) + }; + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs new file mode 100644 index 000000000..676be9091 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.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 'MessageListResponse' through one deserialization pass. +/// +internal sealed record MessageListResponseEnvelope +{ + [JsonPropertyName("data")] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs index e4f7cc019..b6c23dc57 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs @@ -10,10 +10,12 @@ internal sealed class OpenCodeErrorJsonConverter : JsonConverter { private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) { + ["InvalidCursorError"] = typeof(InvalidCursorError), ["InvalidRequestError"] = typeof(InvalidRequestError), ["MessageNotFoundError"] = typeof(MessageNotFoundError), ["SessionNotFoundError"] = typeof(SessionNotFoundError), - ["UnauthorizedError"] = typeof(UnauthorizedError) + ["UnauthorizedError"] = typeof(UnauthorizedError), + ["UnknownError"] = typeof(UnknownError) }; public override bool HandleNull => true; diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs index 861486ed8..50eb1b110 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs @@ -6,8 +6,12 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata, RespectNullableAnnotations = true)] +[JsonSerializable(typeof(FileDiffInfo))] +[JsonSerializable(typeof(FileDiffInfoStatus))] +[JsonSerializable(typeof(InvalidCursorError))] [JsonSerializable(typeof(InvalidRequestError))] [JsonSerializable(typeof(LocationRef))] +[JsonSerializable(typeof(MessageListResponseEnvelope))] [JsonSerializable(typeof(MessageNotFoundError))] [JsonSerializable(typeof(ModelRef))] [JsonSerializable(typeof(OpenCodeError))] @@ -19,6 +23,15 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(PromptMention))] [JsonSerializable(typeof(PromptSkillAttachment))] [JsonSerializable(typeof(ServiceHealth))] +[JsonSerializable(typeof(SessionCreateRequest))] +[JsonSerializable(typeof(SessionCreateResponseEnvelope))] +[JsonSerializable(typeof(SessionForkBoundary))] +[JsonSerializable(typeof(SessionForkBoundaryBefore))] +[JsonSerializable(typeof(SessionForkBoundaryThrough))] +[JsonSerializable(typeof(SessionInfo))] +[JsonSerializable(typeof(SessionInfoFork))] +[JsonSerializable(typeof(SessionInfoTime))] +[JsonSerializable(typeof(SessionListResponseEnvelope))] [JsonSerializable(typeof(SessionMessageAgentSelected))] [JsonSerializable(typeof(SessionMessageAgentSelectedTime))] [JsonSerializable(typeof(SessionMessageAssistant))] @@ -67,6 +80,8 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(SessionMessageUser))] [JsonSerializable(typeof(SessionMessageUserTime))] [JsonSerializable(typeof(SessionNotFoundError))] +[JsonSerializable(typeof(SessionResponseEnvelope))] +[JsonSerializable(typeof(SessionRevert))] [JsonSerializable(typeof(SessionStructuredError))] [JsonSerializable(typeof(TokenUsageInfo))] [JsonSerializable(typeof(TokenUsageInfoCache))] @@ -74,8 +89,10 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(ToolFileContent))] [JsonSerializable(typeof(ToolTextContent))] [JsonSerializable(typeof(UnauthorizedError))] +[JsonSerializable(typeof(UnknownError))] [JsonSerializable(typeof(UnknownOpenCodeError))] [JsonSerializable(typeof(UnknownPromptFileSource))] +[JsonSerializable(typeof(UnknownSessionForkBoundary))] [JsonSerializable(typeof(UnknownSessionMessageAssistantContent))] [JsonSerializable(typeof(UnknownSessionMessageCompaction))] [JsonSerializable(typeof(UnknownSessionMessageInfo))] diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionCreateResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionCreateResponseEnvelope.cs new file mode 100644 index 000000000..4959a8772 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionCreateResponseEnvelope.cs @@ -0,0 +1,14 @@ +// 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 'SessionCreateResponse' through one deserialization pass. +/// +internal sealed record SessionCreateResponseEnvelope +{ + [JsonPropertyName("data")] + public required SessionInfo Data { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionForkBoundaryJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionForkBoundaryJsonConverter.cs new file mode 100644 index 000000000..1908e1564 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionForkBoundaryJsonConverter.cs @@ -0,0 +1,73 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class SessionForkBoundaryJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal) + { + ["before"] = typeof(SessionForkBoundaryBefore), + ["through"] = typeof(SessionForkBoundaryThrough) + }; + public override bool HandleNull => true; + + public override SessionForkBoundary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionForkBoundary payload cannot be null."); + } + + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionForkBoundary payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionForkBoundary payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionForkBoundary."); + return JsonSerializer.Deserialize(payload, typeInfo) as SessionForkBoundary ?? throw new JsonException("The SessionForkBoundary payload deserialized to null."); + } + + return new UnknownSessionForkBoundary(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, SessionForkBoundary value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + if (value is UnknownSessionForkBoundary unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionForkBoundary."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs new file mode 100644 index 000000000..dbb658056 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.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 'SessionListResponse' through one deserialization pass. +/// +internal sealed record SessionListResponseEnvelope +{ + [JsonPropertyName("data")] + public required IReadOnlyList Data { get; init; } + + [JsonPropertyName("cursor")] + public required ListCursor Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionResponseEnvelope.cs new file mode 100644 index 000000000..c5cb5d3f0 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionResponseEnvelope.cs @@ -0,0 +1,14 @@ +// 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 'SessionResponse' through one deserialization pass. +/// +internal sealed record SessionResponseEnvelope +{ + [JsonPropertyName("data")] + public required SessionInfo Data { get; init; } +} diff --git a/src/OpenCode.Sdk/MessageListOptions.cs b/src/OpenCode.Sdk/MessageListOptions.cs new file mode 100644 index 000000000..299b711a4 --- /dev/null +++ b/src/OpenCode.Sdk/MessageListOptions.cs @@ -0,0 +1,9 @@ +// 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/session/{sessionID}/message' query. +/// +public sealed record MessageListOptions : ListOptions +{ +} diff --git a/src/OpenCode.Sdk/MessageListResponse.cs b/src/OpenCode.Sdk/MessageListResponse.cs new file mode 100644 index 000000000..b67030901 --- /dev/null +++ b/src/OpenCode.Sdk/MessageListResponse.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/session/{sessionID}/message' operation. +/// +public sealed record MessageListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _messages; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'MessageListResponse' envelope. + /// + public MessageListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal MessageListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Messages = null!; + Cursor = null!; + } + + /// + /// Gets the Messages payload; guarded on the error path. + /// + public required IReadOnlyList Messages { get => _messages ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Messages."); init => _messages = Array.AsReadOnly([.. value]); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_messages is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Messages = ").Append(_messages); + return true; + } +} diff --git a/src/OpenCode.Sdk/Models/FileDiffInfo.cs b/src/OpenCode.Sdk/Models/FileDiffInfo.cs new file mode 100644 index 000000000..cfda65cb1 --- /dev/null +++ b/src/OpenCode.Sdk/Models/FileDiffInfo.cs @@ -0,0 +1,40 @@ +// 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 file diff info value. +/// +public sealed record FileDiffInfo +{ + /// + /// Gets the file value. + /// + [JsonPropertyName("file")] + public required string File { get; init; } + + /// + /// Gets the patch value. + /// + [JsonPropertyName("patch")] + public required string Patch { get; init; } + + /// + /// Gets the additions value. + /// + [JsonPropertyName("additions")] + public required long Additions { get; init; } + + /// + /// Gets the deletions value. + /// + [JsonPropertyName("deletions")] + public required long Deletions { get; init; } + + /// + /// Gets the status value. + /// + [JsonPropertyName("status")] + public required FileDiffInfoStatus Status { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs b/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs new file mode 100644 index 000000000..d71ce4696 --- /dev/null +++ b/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs @@ -0,0 +1,27 @@ +// 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; +/// +/// Defines the supported file diff info status values. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FileDiffInfoStatus +{ + /// + /// Represents the 'added' wire value. + /// + [JsonStringEnumMemberName("added")] + Added, + /// + /// Represents the 'deleted' wire value. + /// + [JsonStringEnumMemberName("deleted")] + Deleted, + /// + /// Represents the 'modified' wire value. + /// + [JsonStringEnumMemberName("modified")] + Modified +} diff --git a/src/OpenCode.Sdk/Models/InvalidCursorError.cs b/src/OpenCode.Sdk/Models/InvalidCursorError.cs new file mode 100644 index 000000000..6022afe53 --- /dev/null +++ b/src/OpenCode.Sdk/Models/InvalidCursorError.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 invalid cursor error value. +/// +public sealed record InvalidCursorError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "InvalidCursorError"; + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionCreateRequest.cs b/src/OpenCode.Sdk/Models/SessionCreateRequest.cs new file mode 100644 index 000000000..6bd7c4c74 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionCreateRequest.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.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session create request value. +/// +public sealed record SessionCreateRequest +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; init; } + + /// + /// Gets the title value. + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// + /// Gets the agent value. + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Agent { get; init; } + + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelRef? Model { get; init; } + + /// + /// Gets the location value. + /// + [JsonPropertyName("location")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LocationRef? Location { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionForkBoundary.cs b/src/OpenCode.Sdk/Models/SessionForkBoundary.cs new file mode 100644 index 000000000..2ce0e0a00 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionForkBoundary.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.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Represents a session fork boundary union. +/// +[JsonConverter(typeof(SessionForkBoundaryJsonConverter))] +public abstract record SessionForkBoundary +{ + /// + /// Gets the 'type' union marker. + /// + [JsonPropertyName("type")] + public abstract string Type { get; } +} diff --git a/src/OpenCode.Sdk/Models/SessionForkBoundaryBefore.cs b/src/OpenCode.Sdk/Models/SessionForkBoundaryBefore.cs new file mode 100644 index 000000000..f6ca945eb --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionForkBoundaryBefore.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 session fork boundary before value. +/// +public sealed record SessionForkBoundaryBefore : SessionForkBoundary +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "before"; + + /// + /// Gets the message id value. + /// + [JsonPropertyName("messageID")] + public required string MessageId { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionForkBoundaryThrough.cs b/src/OpenCode.Sdk/Models/SessionForkBoundaryThrough.cs new file mode 100644 index 000000000..8ac08bf72 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionForkBoundaryThrough.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 session fork boundary through value. +/// +public sealed record SessionForkBoundaryThrough : SessionForkBoundary +{ + /// + /// Gets the type value. + /// + [JsonPropertyName("type")] + public override string Type => "through"; + + /// + /// Gets the message id value. + /// + [JsonPropertyName("messageID")] + public required string MessageId { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionInfo.cs b/src/OpenCode.Sdk/Models/SessionInfo.cs new file mode 100644 index 000000000..a0db878b6 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionInfo.cs @@ -0,0 +1,95 @@ +// 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 info value. +/// +public sealed record SessionInfo +{ + /// + /// Gets the id value. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// Gets the parent id value. + /// + [JsonPropertyName("parentID")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ParentId { get; init; } + + /// + /// Gets the fork value. + /// + [JsonPropertyName("fork")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionInfoFork? Fork { get; init; } + + /// + /// Gets the project id value. + /// + [JsonPropertyName("projectID")] + public required string ProjectId { get; init; } + + /// + /// Gets the agent value. + /// + [JsonPropertyName("agent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Agent { get; init; } + + /// + /// Gets the model value. + /// + [JsonPropertyName("model")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ModelRef? Model { get; init; } + + /// + /// Gets the cost value. + /// + [JsonPropertyName("cost")] + public required double Cost { get; init; } + + /// + /// Gets the tokens value. + /// + [JsonPropertyName("tokens")] + public required TokenUsageInfo Tokens { get; init; } + + /// + /// Gets the time value. + /// + [JsonPropertyName("time")] + public required SessionInfoTime Time { get; init; } + + /// + /// Gets the title value. + /// + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; init; } + + /// + /// Gets the location value. + /// + [JsonPropertyName("location")] + public required LocationRef Location { get; init; } + + /// + /// Gets the subpath value. + /// + [JsonPropertyName("subpath")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Subpath { get; init; } + + /// + /// Gets the revert value. + /// + [JsonPropertyName("revert")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SessionRevert? Revert { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionInfoFork.cs b/src/OpenCode.Sdk/Models/SessionInfoFork.cs new file mode 100644 index 000000000..b708a2b37 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionInfoFork.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 session info fork value. +/// +public sealed record SessionInfoFork +{ + /// + /// Gets the session id value. + /// + [JsonPropertyName("sessionID")] + public required string SessionId { get; init; } + + /// + /// Gets the boundary value. + /// + [JsonPropertyName("boundary")] + public required SessionForkBoundary Boundary { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionInfoTime.cs b/src/OpenCode.Sdk/Models/SessionInfoTime.cs new file mode 100644 index 000000000..771ae2d44 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionInfoTime.cs @@ -0,0 +1,29 @@ +// 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 info time value. +/// +public sealed record SessionInfoTime +{ + /// + /// Gets the created value. + /// + [JsonPropertyName("created")] + public required double Created { get; init; } + + /// + /// Gets the updated value. + /// + [JsonPropertyName("updated")] + public required double Updated { get; init; } + + /// + /// Gets the archived value. + /// + [JsonPropertyName("archived")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? Archived { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/SessionRevert.cs b/src/OpenCode.Sdk/Models/SessionRevert.cs new file mode 100644 index 000000000..e1e011124 --- /dev/null +++ b/src/OpenCode.Sdk/Models/SessionRevert.cs @@ -0,0 +1,37 @@ +// 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 session revert value. +/// +public sealed record SessionRevert +{ + /// + /// Gets the message id value. + /// + [JsonPropertyName("messageID")] + public required string MessageId { get; init; } + + /// + /// Gets the part id value. + /// + [JsonPropertyName("partID")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PartId { get; init; } + + /// + /// Gets the snapshot value. + /// + [JsonPropertyName("snapshot")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Snapshot { get; init; } + + /// + /// Gets the files value. + /// + [JsonPropertyName("files")] + public IReadOnlyList Files { get; init => field = OptionalCollectionInput.Normalize(value, field, static input => new List(input).AsReadOnly()); } = []; +} diff --git a/src/OpenCode.Sdk/Models/UnknownError.cs b/src/OpenCode.Sdk/Models/UnknownError.cs new file mode 100644 index 000000000..b2e67db5f --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownError.cs @@ -0,0 +1,29 @@ +// 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 unknown error value. +/// +public sealed record UnknownError : OpenCodeError +{ + /// + /// Gets the tag value. + /// + [JsonPropertyName("_tag")] + public override string Tag => "UnknownError"; + + /// + /// Gets the message value. + /// + [JsonPropertyName("message")] + public required string Message { get; init; } + + /// + /// Gets the ref value. + /// + [JsonPropertyName("ref")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Ref { get; init; } +} diff --git a/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs new file mode 100644 index 000000000..74540f98c --- /dev/null +++ b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.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; +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown session fork boundary payload. +/// +public sealed record UnknownSessionForkBoundary : SessionForkBoundary +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownSessionForkBoundary(string type, JsonElement payload) + { + ArgumentException.ThrowIfNullOrEmpty(type); + _marker = type; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'type' marker. + /// + [JsonPropertyName("type")] + public override string Type => _marker; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index 10863cba6..ed96b2777 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -1,5 +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; + namespace OpenCode.Sdk; /// /// Defines the wire route of every generated operation. @@ -22,6 +24,10 @@ public static class Health /// public static class Sessions { + /// + /// The 'POST /api/session' route. + /// + public const string CreateSession = "/api/session"; /// /// The 'GET /api/session/{sessionID}/message/{messageID}' route template. /// @@ -48,5 +54,86 @@ public static string GetMessage(string sessionId, string messageId) return "/api/session/" + Uri.EscapeDataString(sessionId) + "/message/" + Uri.EscapeDataString(messageId); } + + /// + /// The 'GET /api/session/{sessionID}' route template. + /// + public const string GetSessionTemplate = "/api/session/{sessionID}"; + /// + /// Builds the '/api/session/{sessionID}' route. + /// + /// The 'sessionID' route value. + /// The escaped route. + public static string GetSession(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 'GET /api/session/{sessionID}/message' route template. + /// + public const string ListMessagesTemplate = "/api/session/{sessionID}/message"; + /// + /// Builds the '/api/session/{sessionID}/message' route. + /// + /// The 'sessionID' route value. + /// The query options. + /// The escaped route. + public static string ListMessages(string sessionId, MessageListOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + + var path = "/api/session/" + Uri.EscapeDataString(sessionId) + "/message"; + if (options is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddCount("limit", options.Limit); + query.AddOrder("order", options.Order); + query.AddText("cursor", options.Cursor); + return path + query.Value; + } + + /// + /// The 'GET /api/session' route template. + /// + public const string ListSessionsTemplate = "/api/session"; + /// + /// Builds the '/api/session' route. + /// + /// The query options. + /// The escaped route. + public static string ListSessions(SessionListOptions? options = null) + { + var path = "/api/session"; + if (options is null) + { + return path; + } + + var query = new QueryStringBuilder(); + query.AddText("workspace", options.Workspace); + query.AddCount("limit", options.Limit); + query.AddOrder("order", options.Order); + query.AddText("search", options.Search); + query.AddParentFilter("parentID", options.ParentId); + query.AddText("directory", options.Directory); + query.AddText("project", options.Project); + query.AddText("subpath", options.Subpath); + query.AddText("cursor", options.Cursor); + return path + query.Value; + } } } diff --git a/src/OpenCode.Sdk/SessionClient.cs b/src/OpenCode.Sdk/SessionClient.cs index 45f8a4679..903898f8e 100644 --- a/src/OpenCode.Sdk/SessionClient.cs +++ b/src/OpenCode.Sdk/SessionClient.cs @@ -43,4 +43,31 @@ public virtual Task GetMessageAsync(string messageId, Op ArgumentException.ThrowIfNullOrEmpty(messageId); return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.GetMessage(SessionId, messageId), SessionMessageResponseAdapter.Instance, requestOptions, cancellationToken); } + + /// + /// Get session + /// + /// The per-call options. + /// The cancellation token. + /// The 'SessionResponse' 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 GetSessionAsync(OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.GetSession(SessionId), SessionResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// Get session messages + /// + /// The query options. + /// The per-call options. + /// The cancellation token. + /// The 'MessageListResponse' envelope. + /// The API returned an error status (declared: 400, 401, 404, 500) and NoThrow was not selected. + /// The server could not be reached or returned a malformed success body. + public virtual Task ListMessagesAsync(MessageListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListMessages(SessionId, options), MessageListResponseAdapter.Instance, requestOptions, cancellationToken); + } } diff --git a/src/OpenCode.Sdk/SessionCreateResponse.cs b/src/OpenCode.Sdk/SessionCreateResponse.cs new file mode 100644 index 000000000..2c2134ef9 --- /dev/null +++ b/src/OpenCode.Sdk/SessionCreateResponse.cs @@ -0,0 +1,59 @@ +// 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/session' operation. +/// +public sealed record SessionCreateResponse : OpenCodeResponse +{ + private readonly SessionInfo? _session; + /// + /// Initializes a success instance of the 'SessionCreateResponse' envelope. + /// + public SessionCreateResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionCreateResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Session = null!; + } + + /// + /// Gets the Session payload; guarded on the error path. + /// + public required SessionInfo Session { get => _session ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Session."); init => _session = 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 (_session is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Session = ").Append(_session); + return true; + } +} diff --git a/src/OpenCode.Sdk/SessionListOptions.cs b/src/OpenCode.Sdk/SessionListOptions.cs new file mode 100644 index 000000000..cfc88c93f --- /dev/null +++ b/src/OpenCode.Sdk/SessionListOptions.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. +namespace OpenCode.Sdk; +/// +/// Shapes the 'GET /api/session' query. +/// +public sealed record SessionListOptions +{ + /// + /// Gets the 'workspace' query value; the server default applies when unset. + /// + public string? Workspace { get; init; } + /// + /// Gets the 'limit' query value; the server default applies when unset. + /// + public int? Limit { get; init; } + /// + /// Gets the 'order' query value; the server default applies when unset. + /// + public ListOrder? Order { get; init; } + /// + /// Gets the 'search' query value; the server default applies when unset. + /// + public string? Search { get; init; } + /// + /// Gets the 'parentID' query value; the server default applies when unset. + /// + public SessionParentFilter? ParentId { get; init; } + /// + /// Gets the 'directory' query value; the server default applies when unset. + /// + public string? Directory { get; init; } + /// + /// Gets the 'project' query value; the server default applies when unset. + /// + public string? Project { get; init; } + /// + /// Gets the 'subpath' query value; the server default applies when unset. + /// + public string? Subpath { get; init; } + /// + /// Gets the 'cursor' query value; the server default applies when unset. + /// + public string? Cursor { get; init; } +} diff --git a/src/OpenCode.Sdk/SessionListResponse.cs b/src/OpenCode.Sdk/SessionListResponse.cs new file mode 100644 index 000000000..c660b0fd3 --- /dev/null +++ b/src/OpenCode.Sdk/SessionListResponse.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/session' operation. +/// +public sealed record SessionListResponse : OpenCodeResponse +{ + private readonly IReadOnlyList? _sessions; + private readonly ListCursor? _cursor; + /// + /// Initializes a success instance of the 'SessionListResponse' envelope. + /// + public SessionListResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionListResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Sessions = null!; + Cursor = null!; + } + + /// + /// Gets the Sessions payload; guarded on the error path. + /// + public required IReadOnlyList Sessions { get => _sessions ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Sessions."); init => _sessions = Array.AsReadOnly([.. value]); } + /// + /// Gets the page cursor; guarded on the error path. + /// + public required ListCursor Cursor { get => _cursor ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Cursor."); init => _cursor = 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 (_sessions is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Sessions = ").Append(_sessions); + return true; + } +} diff --git a/src/OpenCode.Sdk/SessionResponse.cs b/src/OpenCode.Sdk/SessionResponse.cs new file mode 100644 index 000000000..c3b3ae8c7 --- /dev/null +++ b/src/OpenCode.Sdk/SessionResponse.cs @@ -0,0 +1,59 @@ +// 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/session/{sessionID}' operation. +/// +public sealed record SessionResponse : OpenCodeResponse +{ + private readonly SessionInfo? _session; + /// + /// Initializes a success instance of the 'SessionResponse' envelope. + /// + public SessionResponse() + { + } + + /// + /// Initializes an error-path instance; the payload stays unset behind its guard. + /// + [SetsRequiredMembers] + internal SessionResponse(int status, OpenCodeError? error, string? rawBody) + { + Status = status; + IsError = true; + Error = error; + RawBody = rawBody; + Session = null!; + } + + /// + /// Gets the Session payload; guarded on the error path. + /// + public required SessionInfo Session { get => _session ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Session."); init => _session = 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 (_session is null) + { + return printed; + } + + if (printed) + { + _ = builder.Append(", "); + } + + _ = builder.Append("Session = ").Append(_session); + return true; + } +} diff --git a/src/OpenCode.Sdk/SessionsClient.cs b/src/OpenCode.Sdk/SessionsClient.cs index d24afb4e8..74f962fe3 100644 --- a/src/OpenCode.Sdk/SessionsClient.cs +++ b/src/OpenCode.Sdk/SessionsClient.cs @@ -1,6 +1,9 @@ // 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; /// @@ -34,4 +37,32 @@ public virtual SessionClient GetSessionClient(string sessionId) } private Pipeline Pipeline => _pipeline ?? throw MockSeam.CreateError("SessionsClient", "Pipeline"); + + /// + /// Create session + /// + /// The request body; an empty body is sent when omitted. + /// The per-call options. + /// The cancellation token. + /// The 'SessionCreateResponse' 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 CreateSessionAsync(SessionCreateRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Post, OpenCodeRoutes.Sessions.CreateSession, request ?? new SessionCreateRequest(), OpenCodeJsonContext.Default.SessionCreateRequest, SessionCreateResponseAdapter.Instance, requestOptions, cancellationToken); + } + + /// + /// List sessions + /// + /// The query options. + /// The per-call options. + /// The cancellation token. + /// The 'SessionListResponse' 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 ListSessionsAsync(SessionListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListSessions(options), SessionListResponseAdapter.Instance, requestOptions, cancellationToken); + } } diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 6714d4aa8..30e2cb59c 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -31,6 +31,17 @@ Ascending = 0, Descending = 1, } + public sealed class MessageListOptions : OpenCode.Sdk.ListOptions, System.IEquatable + { + public MessageListOptions() { } + } + public sealed class MessageListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public MessageListResponse() { } + public required OpenCode.Sdk.ListCursor Cursor { get; init; } + public required System.Collections.Generic.IReadOnlyList Messages { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } public class OpenCodeApiException : OpenCode.Sdk.OpenCodeException { public OpenCodeApiException() { } @@ -86,8 +97,15 @@ } public static class Sessions { + public const string CreateSession = "/api/session"; public const string GetMessageTemplate = "/api/session/{sessionID}/message/{messageID}"; + public const string GetSessionTemplate = "/api/session/{sessionID}"; + public const string ListMessagesTemplate = "/api/session/{sessionID}/message"; + public const string ListSessionsTemplate = "/api/session"; public static string GetMessage(string sessionId, string messageId) { } + public static string GetSession(string sessionId) { } + public static string ListMessages(string sessionId, OpenCode.Sdk.MessageListOptions? options = null) { } + public static string ListSessions(OpenCode.Sdk.SessionListOptions? options = null) { } } } public class OpenCodeTransportException : OpenCode.Sdk.OpenCodeException @@ -100,6 +118,34 @@ { protected SessionClient() { } 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.MessageListOptions? options = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + } + public sealed class SessionCreateResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionCreateResponse() { } + public required OpenCode.Sdk.Models.SessionInfo Session { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } + public sealed class SessionListOptions : System.IEquatable + { + public SessionListOptions() { } + public string? Cursor { get; init; } + public string? Directory { get; init; } + public int? Limit { get; init; } + public OpenCode.Sdk.ListOrder? Order { get; init; } + public OpenCode.Sdk.SessionParentFilter? ParentId { get; init; } + public string? Project { get; init; } + public string? Search { get; init; } + public string? Subpath { get; init; } + public string? Workspace { get; init; } + } + public sealed class SessionListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionListResponse() { } + public required OpenCode.Sdk.ListCursor Cursor { get; init; } + public required System.Collections.Generic.IReadOnlyList Sessions { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } } public sealed class SessionMessageResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { @@ -112,14 +158,54 @@ public static OpenCode.Sdk.SessionParentFilter RootOnly { get; } public static OpenCode.Sdk.SessionParentFilter Of(string sessionId) { } } + public sealed class SessionResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable + { + public SessionResponse() { } + public required OpenCode.Sdk.Models.SessionInfo Session { get; init; } + protected override bool PrintMembers(System.Text.StringBuilder builder) { } + } public class SessionsClient { protected SessionsClient() { } + public virtual System.Threading.Tasks.Task CreateSessionAsync(OpenCode.Sdk.Models.SessionCreateRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual OpenCode.Sdk.SessionClient GetSessionClient(string sessionId) { } + public virtual System.Threading.Tasks.Task ListSessionsAsync(OpenCode.Sdk.SessionListOptions? options = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } } namespace OpenCode.Sdk.Models { + public sealed class FileDiffInfo : System.IEquatable + { + public FileDiffInfo() { } + [System.Text.Json.Serialization.JsonPropertyName("additions")] + public required long Additions { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("deletions")] + public required long Deletions { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("file")] + public required string File { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("patch")] + public required string Patch { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("status")] + public required OpenCode.Sdk.Models.FileDiffInfoStatus Status { get; init; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public enum FileDiffInfoStatus + { + [System.Text.Json.Serialization.JsonStringEnumMemberName("added")] + Added = 0, + [System.Text.Json.Serialization.JsonStringEnumMemberName("deleted")] + Deleted = 1, + [System.Text.Json.Serialization.JsonStringEnumMemberName("modified")] + Modified = 2, + } + public sealed class InvalidCursorError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable + { + public InvalidCursorError() { } + [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 InvalidRequestError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable { public InvalidRequestError() { } @@ -255,6 +341,104 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("version")] public required string Version { get; init; } } + public sealed class SessionCreateRequest : System.IEquatable + { + public SessionCreateRequest() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("agent")] + public string? Agent { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string? Id { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("location")] + public OpenCode.Sdk.Models.LocationRef? Location { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("model")] + public OpenCode.Sdk.Models.ModelRef? Model { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("title")] + public string? Title { get; init; } + } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.SessionForkBoundaryJsonConverter))] + public abstract class SessionForkBoundary : System.IEquatable + { + protected SessionForkBoundary() { } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public abstract string Type { get; } + } + public sealed class SessionForkBoundaryBefore : OpenCode.Sdk.Models.SessionForkBoundary, System.IEquatable + { + public SessionForkBoundaryBefore() { } + [System.Text.Json.Serialization.JsonPropertyName("messageID")] + public required string MessageId { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public override string Type { get; } + } + public sealed class SessionForkBoundaryThrough : OpenCode.Sdk.Models.SessionForkBoundary, System.IEquatable + { + public SessionForkBoundaryThrough() { } + [System.Text.Json.Serialization.JsonPropertyName("messageID")] + public required string MessageId { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public override string Type { get; } + } + public sealed class SessionInfo : System.IEquatable + { + public SessionInfo() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("agent")] + public string? Agent { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("cost")] + public required double Cost { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("fork")] + public OpenCode.Sdk.Models.SessionInfoFork? Fork { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("id")] + public required string Id { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("location")] + public required OpenCode.Sdk.Models.LocationRef Location { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("model")] + public OpenCode.Sdk.Models.ModelRef? Model { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("parentID")] + public string? ParentId { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("projectID")] + public required string ProjectId { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("revert")] + public OpenCode.Sdk.Models.SessionRevert? Revert { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("subpath")] + public string? Subpath { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("time")] + public required OpenCode.Sdk.Models.SessionInfoTime Time { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("title")] + public string? Title { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("tokens")] + public required OpenCode.Sdk.Models.TokenUsageInfo Tokens { get; init; } + } + public sealed class SessionInfoFork : System.IEquatable + { + public SessionInfoFork() { } + [System.Text.Json.Serialization.JsonPropertyName("boundary")] + public required OpenCode.Sdk.Models.SessionForkBoundary Boundary { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("sessionID")] + public required string SessionId { get; init; } + } + public sealed class SessionInfoTime : System.IEquatable + { + public SessionInfoTime() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("archived")] + public double? Archived { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("created")] + public required double Created { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("updated")] + public required double Updated { get; init; } + } public sealed class SessionMessageAgentSelected : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { public SessionMessageAgentSelected() { } @@ -825,6 +1009,20 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class SessionRevert : System.IEquatable + { + public SessionRevert() { } + [System.Text.Json.Serialization.JsonPropertyName("files")] + public System.Collections.Generic.IReadOnlyList Files { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("messageID")] + public required string MessageId { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("partID")] + public string? PartId { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("snapshot")] + public string? Snapshot { get; init; } + } public sealed class SessionStructuredError : System.IEquatable { public SessionStructuredError() { } @@ -892,6 +1090,17 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + public sealed class UnknownError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable + { + public UnknownError() { } + [System.Text.Json.Serialization.JsonPropertyName("message")] + public required string Message { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("ref")] + public string? Ref { get; init; } + [System.Text.Json.Serialization.JsonPropertyName("_tag")] + public override string Tag { get; } + } public sealed class UnknownOpenCodeError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -908,6 +1117,14 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + public sealed class UnknownSessionForkBoundary : OpenCode.Sdk.Models.SessionForkBoundary, System.IEquatable + { + [System.Text.Json.Serialization.JsonConstructor] + public UnknownSessionForkBoundary(string type, System.Text.Json.JsonElement payload) { } + public System.Text.Json.JsonElement Payload { get; } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public override string Type { get; } + } public sealed class UnknownSessionMessageAssistantContent : OpenCode.Sdk.Models.SessionMessageAssistantContent, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 3302daeaa..a62aa44a4 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -45,20 +45,56 @@ await Assert.That(health.ErrorMap.Statuses.Select(static status => status.Status var sessions = plan.Clients.Single(static client => client.Role == ClientRole.Collection); await Assert.That(sessions.Name).IsEqualTo("SessionsClient"); - await Assert.That(sessions.Operations).IsEmpty(); + await Assert.That(sessions.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["CreateSessionAsync", "ListSessionsAsync"], StringComparer.Ordinal)).IsTrue(); await Assert.That(sessions.HandleFactory!.MethodName).IsEqualTo("GetSessionClient"); await Assert.That(sessions.HandleFactory.HandleTypeName).IsEqualTo("SessionClient"); await Assert.That(sessions.HandleFactory.Parameter.WireName).IsEqualTo("sessionID"); await Assert.That(sessions.HandleFactory.Parameter.Name).IsEqualTo("sessionId"); await Assert.That(sessions.HandleFactory.Parameter.TypeName).IsEqualTo("string"); + var list = sessions.Operations.Single(static operation => operation.MethodName == "ListSessionsAsync"); + await Assert.That(list.Options!.TypeName).IsEqualTo("SessionListOptions"); + await Assert.That(list.Options.DerivesFromListOptions).IsFalse(); + await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + .SequenceEqual( + ["Workspace", "Limit", "Order", "Search", "ParentId", "Directory", "Project", "Subpath", "Cursor"], + StringComparer.Ordinal)).IsTrue(); + await Assert.That(list.Envelope.Kind).IsEqualTo(EnvelopeKind.CursorList); + await Assert.That(list.Envelope.PayloadName).IsEqualTo("Sessions"); + await Assert.That(list.Envelope.PayloadTypeName).IsEqualTo("SessionInfo"); + await Assert.That(list.ErrorMap.Statuses[0].Tags.Select(static tag => tag.TypeName) + .SequenceEqual(["InvalidCursorError", "InvalidRequestError"], StringComparer.Ordinal)).IsTrue(); + + var create = sessions.Operations.Single(static operation => operation.MethodName == "CreateSessionAsync"); + await Assert.That(create.HttpMethod).IsEqualTo("post"); + await Assert.That(create.RequestBody!.TypeName).IsEqualTo("SessionCreateRequest"); + await Assert.That(create.RequestBody.IsOptional).IsTrue(); + await Assert.That(create.Envelope.ResponseTypeName).IsEqualTo("SessionCreateResponse"); + await Assert.That(create.Envelope.PayloadName).IsEqualTo("Session"); + } + + [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"); await Assert.That(session.HandleParameter!.WireName).IsEqualTo("sessionID"); await Assert.That(session.HandleParameter.IsHandleParameter).IsTrue(); - - var message = session.Operations.Single(); - await Assert.That(message.MethodName).IsEqualTo("GetMessageAsync"); + await Assert.That(session.Operations.Select(static operation => operation.MethodName) + .SequenceEqual(["GetMessageAsync", "GetSessionAsync", "ListMessagesAsync"], StringComparer.Ordinal)).IsTrue(); + + var messages = session.Operations.Single(static operation => operation.MethodName == "ListMessagesAsync"); + await Assert.That(messages.Options!.TypeName).IsEqualTo("MessageListOptions"); + await Assert.That(messages.Options.DerivesFromListOptions).IsTrue(); + await Assert.That(messages.Envelope.Kind).IsEqualTo(EnvelopeKind.CursorList); + await Assert.That(messages.Envelope.PayloadName).IsEqualTo("Messages"); + await Assert.That(messages.ErrorMap.Statuses.Select(static status => status.StatusCode) + .SequenceEqual([400, 401, 404, 500])).IsTrue(); + + var message = session.Operations.Single(static operation => operation.MethodName == "GetMessageAsync"); await Assert.That(message.RouteTemplate).IsEqualTo("/api/session/{sessionID}/message/{messageID}"); await Assert.That(message.RouteContainerName).IsEqualTo("Sessions"); await Assert.That(message.RouteMemberName).IsEqualTo("GetMessage"); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index 5b298ac70..ff12f664d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -12,10 +12,12 @@ public sealed class SpecBinderTests { private static readonly string[] ExpectedErrorTypeNames = [ + "InvalidCursorError", "InvalidRequestError", "MessageNotFoundError", "SessionNotFoundError", "UnauthorizedError", + "UnknownError", ]; [Test] diff --git a/tools/curation.json b/tools/curation.json index f9fbcf288..6fbbac583 100644 --- a/tools/curation.json +++ b/tools/curation.json @@ -8,6 +8,12 @@ "clientName": "Sessions", "handleName": "SessionClient", "handleParameter": "sessionID" + }, + "message": { + "placement": "client", + "clientName": "Sessions", + "handleName": "SessionClient", + "handleParameter": "sessionID" } }, "envelopePayloadNames": {}, @@ -25,5 +31,11 @@ "reason": "The wire field carries a URI, but the pinned schema omits format: uri." } ], - "schemaAliases": [] + "schemaAliases": [ + { + "schema": "InvalidRequestError1", + "aliasOf": "InvalidRequestError", + "reason": "Upstream spec-gen emits a structurally identical duplicate carrying the same _tag." + } + ] } diff --git a/tools/generation-profile.txt b/tools/generation-profile.txt index 94a70703e..83e812908 100644 --- a/tools/generation-profile.txt +++ b/tools/generation-profile.txt @@ -1,2 +1,6 @@ v2.health.get +v2.message.list +v2.session.create +v2.session.get +v2.session.list v2.session.message From 9c9ce6118dee8599aed2212cd3cdfe69833b7244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 03:16:04 +0300 Subject: [PATCH 13/47] test(sdk): pin the breadth-batch contract matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract tests cover the four new operations end to end: typed pages with cursor round-trips, escaped query composition, the sealed non-positive limit refusal at the route boundary, both declared 400 tags on session.list, the empty-body and typed-body create paths, every remaining declared error status including the first 5xx arm on both error channels, undeclared-2xx and missing-envelope protocol failures. The matrix caught a real defect: the error-path constructor pushed its forgiven null through the list payload's defensive-copy init accessor, crashing every cursor-list error before the guard could hold — the copy now passes the forgiven null through uncopied, and the envelope snapshot records the shape. --- src/OpenCode.Sdk/MessageListResponse.cs | 2 +- src/OpenCode.Sdk/SessionListResponse.cs | 2 +- .../Fixtures/Serialization/known-session.json | 23 +++ .../SessionClientContractTests.cs | 148 ++++++++++++++++ .../SessionsClientContractTests.cs | 166 ++++++++++++++++++ ...ce_Guarded_Response_Envelopes.verified.txt | 4 +- .../Generator/Emission/EnvelopeEmitter.cs | 30 ++-- 7 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session.json create mode 100644 tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs create mode 100644 tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs diff --git a/src/OpenCode.Sdk/MessageListResponse.cs b/src/OpenCode.Sdk/MessageListResponse.cs index b67030901..708db6b8b 100644 --- a/src/OpenCode.Sdk/MessageListResponse.cs +++ b/src/OpenCode.Sdk/MessageListResponse.cs @@ -36,7 +36,7 @@ internal MessageListResponse(int status, OpenCodeError? error, string? rawBody) /// /// Gets the Messages payload; guarded on the error path. /// - public required IReadOnlyList Messages { get => _messages ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Messages."); init => _messages = Array.AsReadOnly([.. value]); } + public required IReadOnlyList Messages { get => _messages ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Messages."); init => _messages = value is null ? null : Array.AsReadOnly([.. value]); } /// /// Gets the page cursor; guarded on the error path. /// diff --git a/src/OpenCode.Sdk/SessionListResponse.cs b/src/OpenCode.Sdk/SessionListResponse.cs index c660b0fd3..10e0193ab 100644 --- a/src/OpenCode.Sdk/SessionListResponse.cs +++ b/src/OpenCode.Sdk/SessionListResponse.cs @@ -36,7 +36,7 @@ internal SessionListResponse(int status, OpenCodeError? error, string? rawBody) /// /// Gets the Sessions payload; guarded on the error path. /// - public required IReadOnlyList Sessions { get => _sessions ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Sessions."); init => _sessions = Array.AsReadOnly([.. value]); } + public required IReadOnlyList Sessions { get => _sessions ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Sessions."); init => _sessions = value is null ? null : Array.AsReadOnly([.. value]); } /// /// Gets the page cursor; guarded on the error path. /// diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session.json new file mode 100644 index 000000000..c1e42f764 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session.json @@ -0,0 +1,23 @@ +{ + "id": "ses_100", + "projectID": "prj_1", + "title": "Fix the build", + "cost": 0.42, + "tokens": { + "input": 10, + "output": 20, + "reasoning": 0, + "cache": { + "read": 1, + "write": 2 + } + }, + "time": { + "created": 1755100000, + "updated": 1755100050 + }, + "location": { + "directory": "/repo", + "workspaceID": "wrk_1" + } +} diff --git a/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs new file mode 100644 index 000000000..5b71ccba0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; + +namespace OpenCode.Sdk.Tests; + +public sealed class SessionClientContractTests +{ + private static readonly Uri Endpoint = new("http://localhost:4096"); + + [Test] + public async Task GetSessionAsync_Should_Return_The_Typed_Session() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync(); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.Session.Id).IsEqualTo("ses_100"); + await Assert.That(response.Session.Location.Directory).IsEqualTo("/repo"); + await Assert.That(response.Session.Tokens.Cache.Read).IsEqualTo(1); + await Assert.That(handler.Requests.Single().RequestUri) + .IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100")); + } + + [Test] + public async Task GetSessionAsync_Should_Throw_The_Declared_404_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.NotFound, + "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"ses_9\",\"message\":\"gone\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(404); + await Assert.That(exception.Error).IsTypeOf(); + } + + [Test] + public async Task GetSessionAsync_Should_Throw_The_Declared_401_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.Unauthorized, + "{\"_tag\":\"UnauthorizedError\",\"message\":\"password required\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").GetSessionAsync()) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + [Test] + public async Task ListMessagesAsync_Should_Return_The_Typed_Page_With_Its_Cursor() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session-message.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse( + HttpStatusCode.OK, + $"{{\"data\":[{payload}],\"cursor\":{{\"previous\":\"cur_0\"}}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListOptions + { + Limit = 2, + Order = ListOrder.Ascending, + }); + + await Assert.That(response.Messages.Single()).IsTypeOf(); + await Assert.That(response.Cursor.Previous).IsEqualTo("cur_0"); + await Assert.That(response.Cursor.Next).IsNull(); + await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri) + .IsEqualTo("http://localhost:4096/api/session/ses_100/message?limit=2&order=asc"); + } + + [Test] + public async Task ListMessagesAsync_Should_Throw_The_Declared_500_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.InternalServerError, + "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(500); + await Assert.That(exception.Error).IsTypeOf(); + await Assert.That(((UnknownError)exception.Error!).Message).IsEqualTo("boom"); + } + + [Test] + public async Task ListMessagesAsync_Should_Return_The_500_Error_On_The_NoThrow_Spine() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.InternalServerError, + "{\"_tag\":\"UnknownError\",\"message\":\"boom\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.GetSessionClient("ses_100") + .ListMessagesAsync(requestOptions: OpenCodeRequestOptions.NoThrow); + + await Assert.That(response.IsError).IsTrue(); + await Assert.That(response.Status).IsEqualTo(500); + await Assert.That(response.Error).IsTypeOf(); + await Assert.That(response.RawBody).Contains("boom"); + } + + [Test] + public async Task ListMessagesAsync_Should_Throw_The_Declared_404_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.NotFound, + "{\"_tag\":\"SessionNotFoundError\",\"sessionID\":\"ses_9\",\"message\":\"gone\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_9").ListMessagesAsync()) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + private static OpenCodeClient CreateClient(HttpClient httpClient) => + new(httpClient, new OpenCodeClientOptions + { + Endpoint = Endpoint, + }); + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) => + new(status) + { + Content = new StringContent(body), + }; +} diff --git a/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs new file mode 100644 index 000000000..fe5d9490b --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs @@ -0,0 +1,166 @@ +using System.Net; +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; + +namespace OpenCode.Sdk.Tests; + +public sealed class SessionsClientContractTests +{ + private static readonly Uri Endpoint = new("http://localhost:4096"); + + [Test] + public async Task ListSessionsAsync_Should_Return_The_Typed_Page_With_Its_Cursor() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse( + HttpStatusCode.OK, + $"{{\"data\":[{payload}],\"cursor\":{{\"next\":\"cur_2\"}}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.ListSessionsAsync(); + + await Assert.That(response.Status).IsEqualTo(200); + await Assert.That(response.Sessions.Single().Id).IsEqualTo("ses_100"); + await Assert.That(response.Sessions.Single().Title).IsEqualTo("Fix the build"); + await Assert.That(response.Cursor.Next).IsEqualTo("cur_2"); + await Assert.That(response.Cursor.Previous).IsNull(); + await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/session")); + } + + [Test] + public async Task ListSessionsAsync_Should_Compose_The_Escaped_Query() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":[],\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await client.Sessions.ListSessionsAsync(new SessionListOptions + { + Limit = 5, + Order = ListOrder.Descending, + Search = "a b", + ParentId = SessionParentFilter.RootOnly, + Cursor = "cur_1", + }); + + await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( + "http://localhost:4096/api/session?limit=5&order=desc&search=a%20b&parentID=null&cursor=cur_1"); + } + + [Test] + public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Sending() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync(new SessionListOptions { Limit = 0 })) + .Throws(); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + [Arguments("{\"_tag\":\"InvalidCursorError\",\"message\":\"stale\"}", typeof(InvalidCursorError))] + [Arguments("{\"_tag\":\"InvalidRequestError\",\"message\":\"bad\"}", typeof(InvalidRequestError))] + public async Task ListSessionsAsync_Should_Type_Both_Declared_400_Errors(string body, Type expected) + { + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.BadRequest, body)); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + + await Assert.That(exception!.Status).IsEqualTo(400); + await Assert.That(exception.Error!.GetType()).IsEqualTo(expected); + } + + [Test] + public async Task ListSessionsAsync_Should_Treat_An_Undeclared_Success_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse(HttpStatusCode.Accepted, "{}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + + [Test] + public async Task ListSessionsAsync_Should_Treat_A_Missing_Envelope_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse(HttpStatusCode.OK, "{}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + + [Test] + public async Task CreateSessionAsync_Should_Send_The_Empty_Body_When_Omitted() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var response = await client.Sessions.CreateSessionAsync(); + + await Assert.That(response.Session.Id).IsEqualTo("ses_100"); + await Assert.That(handler.Requests.Single().Method).IsEqualTo(HttpMethod.Post); + await Assert.That(handler.Requests.Single().ContentType).IsEqualTo("application/json"); + await Assert.That(handler.Requests.Single().Body).IsEqualTo("{}"); + } + + [Test] + public async Task CreateSessionAsync_Should_Send_The_Typed_Body() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await client.Sessions.CreateSessionAsync(new SessionCreateRequest + { + Title = "Fresh session", + }); + + await Assert.That(handler.Requests.Single().Body).IsEqualTo("{\"title\":\"Fresh session\"}"); + } + + [Test] + public async Task CreateSessionAsync_Should_Throw_The_Declared_400_Error() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.BadRequest, + "{\"_tag\":\"InvalidRequestError\",\"message\":\"bad id\"}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = await Assert + .That(async () => _ = await client.Sessions.CreateSessionAsync()) + .Throws(); + + await Assert.That(exception!.Error).IsTypeOf(); + } + + private static OpenCodeClient CreateClient(HttpClient httpClient) => + new(httpClient, new OpenCodeClientOptions + { + Endpoint = Endpoint, + }); + + private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) => + new(status) + { + Content = new StringContent(body), + }; +} 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 8f6d0ca97..20c8dd378 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 @@ -162,7 +162,7 @@ public sealed record WidgetItemListResponse : OpenCodeResponse /// /// Gets the Items payload; guarded on the error path. /// - public required IReadOnlyList Items { get => _items ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Items."); init => _items = Array.AsReadOnly([..value]); } + public required IReadOnlyList Items { get => _items ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Items."); init => _items = value is null ? null : Array.AsReadOnly([..value]); } /// /// Gets the page cursor; guarded on the error path. /// @@ -292,7 +292,7 @@ public sealed record WidgetListResponse : OpenCodeResponse /// /// Gets the Widgets payload; guarded on the error path. /// - public required IReadOnlyList Widgets { get => _widgets ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widgets."); init => _widgets = Array.AsReadOnly([..value]); } + public required IReadOnlyList Widgets { get => _widgets ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widgets."); init => _widgets = value is null ? null : Array.AsReadOnly([..value]); } /// /// Gets the page cursor; guarded on the error path. /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index 6f8d7d5ba..b55a86d67 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -122,9 +122,11 @@ private static ConstructorDeclarationSyntax EmitErrorConstructor(EnvelopePlan en private static PropertyDeclarationSyntax EmitPayloadProperty(EnvelopePlan envelope, TypeSyntax payloadType, string fieldName) { - ExpressionSyntax initValue = envelope.Kind is EnvelopeKind.CursorList - ? DefensiveListCopy() - : SyntaxFactory.IdentifierName("value"); + ExpressionSyntax initValue = SyntaxFactory.IdentifierName("value"); + if (envelope.Kind is EnvelopeKind.CursorList) + { + initValue = DefensiveListCopy(); + } return EmitGuardedProperty( payloadType, envelope.PayloadName, @@ -141,13 +143,21 @@ private static PropertyDeclarationSyntax EmitCursorProperty() => SyntaxFactory.IdentifierName("value"), "Gets the page cursor; guarded on the error path."); - /// List payloads defensively copy on init so the envelope stays immutable. - private static InvocationExpressionSyntax DefensiveListCopy() => - EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Array"), "AsReadOnly"), - SyntaxFactory.Argument(SyntaxFactory.CollectionExpression( - SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.SpreadElement(SyntaxFactory.IdentifierName("value")))))); + /// + /// List payloads defensively copy on init so the envelope stays immutable; the error + /// path's forgiven null passes through uncopied and stays behind the getter guard. + /// + private static ConditionalExpressionSyntax DefensiveListCopy() => + SyntaxFactory.ConditionalExpression( + SyntaxFactory.IsPatternExpression( + SyntaxFactory.IdentifierName("value"), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression), + EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Array"), "AsReadOnly"), + SyntaxFactory.Argument(SyntaxFactory.CollectionExpression( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SpreadElement(SyntaxFactory.IdentifierName("value"))))))); private static PropertyDeclarationSyntax EmitGuardedProperty(TypeSyntax propertyType, string propertyName, string fieldName, ExpressionSyntax initValue, string documentation) From 375e55fe9cb437c9710e5e2eb9712f61c054ee95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 03:22:19 +0300 Subject: [PATCH 14/47] fix(generator): reproduce the raw document through concrete Unknown* carriers (#19) Every union now emits a concrete-type converter for its Unknown* carrier: writing replays the preserved document instead of the record shape source-generated metadata would emit, and reading reproduces the base converter's fallback arm, so consumer serialization of a concrete carrier can no longer leak a {marker, Payload} record. Round-trip tests cover representative carriers through both the union base and the concrete type; the benchmark suite gains the ListMessagesAsync cursor-page baseline (58.1 us / 28.24 KB) and records the P2 single-pass win on GetMessageAsync (67.4 -> 56.2 us, allocations flat). --- src/OpenCode.Sdk/.generated-manifest.json | 8 ++ .../UnknownOpenCodeErrorJsonConverter.cs | 48 +++++++++ .../UnknownPromptFileSourceJsonConverter.cs | 48 +++++++++ ...UnknownSessionForkBoundaryJsonConverter.cs | 48 +++++++++ ...ionMessageAssistantContentJsonConverter.cs | 48 +++++++++ ...wnSessionMessageCompactionJsonConverter.cs | 48 +++++++++ .../UnknownSessionMessageInfoJsonConverter.cs | 48 +++++++++ ...ownSessionMessageToolStateJsonConverter.cs | 48 +++++++++ .../UnknownToolContentJsonConverter.cs | 48 +++++++++ .../Models/UnknownOpenCodeError.cs | 2 + .../Models/UnknownPromptFileSource.cs | 2 + .../Models/UnknownSessionForkBoundary.cs | 2 + .../UnknownSessionMessageAssistantContent.cs | 2 + .../Models/UnknownSessionMessageCompaction.cs | 2 + .../Models/UnknownSessionMessageInfo.cs | 2 + .../Models/UnknownSessionMessageToolState.cs | 2 + src/OpenCode.Sdk/Models/UnknownToolContent.cs | 2 + .../Benchmarks/ClientOperationBenchmarks.cs | 16 +++ .../Support/BenchmarkFixtures.cs | 7 ++ .../Serialization/OpenCodeJsonContextTests.cs | 33 +++++++ .../Snapshots/PublicApi.verified.txt | 8 ++ ...erant_Marked_Union_Converters.verified.txt | 40 ++++++++ .../Generator/Emission/UnionEmitter.cs | 98 ++++++++++++++++++- 23 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index dbe9761fd..ad52b3265 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -22,6 +22,14 @@ "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", "Internal/Serialization/SessionResponseEnvelope.cs", "Internal/Serialization/ToolContentJsonConverter.cs", + "Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs", + "Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs", + "Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs", + "Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs", + "Internal/Serialization/UnknownToolContentJsonConverter.cs", "MessageListOptions.cs", "MessageListResponse.cs", "Models/FileDiffInfo.cs", diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs new file mode 100644 index 000000000..7fb5d0f52 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownOpenCodeErrorJsonConverter : JsonConverter +{ + public override UnknownOpenCodeError Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The OpenCodeError payload must be a JSON object."); + } + + if (!payload.TryGetProperty("_tag", out var markerElement)) + { + throw new JsonException("The OpenCodeError payload must contain '_tag'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The '_tag' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The '_tag' marker must be a non-empty string."); + } + + return new UnknownOpenCodeError(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownOpenCodeError value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs new file mode 100644 index 000000000..19fb445a8 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownPromptFileSourceJsonConverter : JsonConverter +{ + public override UnknownPromptFileSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The PromptFileSource payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The PromptFileSource payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownPromptFileSource(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownPromptFileSource value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs new file mode 100644 index 000000000..ce1d0cbdd --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionForkBoundaryJsonConverter : JsonConverter +{ + public override UnknownSessionForkBoundary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionForkBoundary payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionForkBoundary payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownSessionForkBoundary(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionForkBoundary value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs new file mode 100644 index 000000000..c2754033b --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageAssistantContentJsonConverter : JsonConverter +{ + public override UnknownSessionMessageAssistantContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageAssistantContent payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionMessageAssistantContent payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownSessionMessageAssistantContent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageAssistantContent value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs new file mode 100644 index 000000000..9e229693b --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageCompactionJsonConverter : JsonConverter +{ + public override UnknownSessionMessageCompaction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageCompaction payload must be a JSON object."); + } + + if (!payload.TryGetProperty("status", out var markerElement)) + { + throw new JsonException("The SessionMessageCompaction payload must contain 'status'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'status' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'status' marker must be a non-empty string."); + } + + return new UnknownSessionMessageCompaction(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageCompaction value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs new file mode 100644 index 000000000..ab475eb0d --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageInfoJsonConverter : JsonConverter +{ + public override UnknownSessionMessageInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageInfo payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The SessionMessageInfo payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownSessionMessageInfo(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageInfo value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs new file mode 100644 index 000000000..c191af87d --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownSessionMessageToolStateJsonConverter : JsonConverter +{ + public override UnknownSessionMessageToolState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The SessionMessageToolState payload must be a JSON object."); + } + + if (!payload.TryGetProperty("status", out var markerElement)) + { + throw new JsonException("The SessionMessageToolState payload must contain 'status'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'status' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'status' marker must be a non-empty string."); + } + + return new UnknownSessionMessageToolState(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownSessionMessageToolState value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs new file mode 100644 index 000000000..0ee40bfbb --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs @@ -0,0 +1,48 @@ +// 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; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; + +internal sealed class UnknownToolContentJsonConverter : JsonConverter +{ + public override UnknownToolContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + { + throw new JsonException("The ToolContent payload must be a JSON object."); + } + + if (!payload.TryGetProperty("type", out var markerElement)) + { + throw new JsonException("The ToolContent payload must contain 'type'."); + } + + if (markerElement.ValueKind != JsonValueKind.String) + { + throw new JsonException("The 'type' marker must be a string."); + } + + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + { + throw new JsonException("The 'type' marker must be a non-empty string."); + } + + return new UnknownToolContent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownToolContent value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs index 130eb3c17..b739f1e21 100644 --- a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs +++ b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown open code error payload. /// +[JsonConverter(typeof(UnknownOpenCodeErrorJsonConverter))] public sealed record UnknownOpenCodeError : OpenCodeError { private readonly string _marker; diff --git a/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs b/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs index adfee4732..507a0e233 100644 --- a/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs +++ b/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown prompt file source payload. /// +[JsonConverter(typeof(UnknownPromptFileSourceJsonConverter))] public sealed record UnknownPromptFileSource : PromptFileSource { private readonly string _marker; diff --git a/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs index 74540f98c..a4edc0052 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session fork boundary payload. /// +[JsonConverter(typeof(UnknownSessionForkBoundaryJsonConverter))] public sealed record UnknownSessionForkBoundary : SessionForkBoundary { private readonly string _marker; diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs index 143c2b238..daa807e12 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message assistant content payload. /// +[JsonConverter(typeof(UnknownSessionMessageAssistantContentJsonConverter))] public sealed record UnknownSessionMessageAssistantContent : SessionMessageAssistantContent { private readonly string _marker; diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs index 96268f855..5b2882ca8 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message compaction payload. /// +[JsonConverter(typeof(UnknownSessionMessageCompactionJsonConverter))] public sealed record UnknownSessionMessageCompaction : SessionMessageCompaction { private readonly string _marker; diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs index c77f46428..c73b6ee2c 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message info payload. /// +[JsonConverter(typeof(UnknownSessionMessageInfoJsonConverter))] public sealed record UnknownSessionMessageInfo : SessionMessageInfo { private readonly string _marker; diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs index 7c1eac2f9..73772f8d7 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown session message tool state payload. /// +[JsonConverter(typeof(UnknownSessionMessageToolStateJsonConverter))] public sealed record UnknownSessionMessageToolState : SessionMessageToolState { private readonly string _marker; diff --git a/src/OpenCode.Sdk/Models/UnknownToolContent.cs b/src/OpenCode.Sdk/Models/UnknownToolContent.cs index e961cbf6e..c6f459c41 100644 --- a/src/OpenCode.Sdk/Models/UnknownToolContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownToolContent.cs @@ -2,11 +2,13 @@ // Do not edit by hand — change tools/curation.json or the emitters, then regenerate. using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown tool content payload. /// +[JsonConverter(typeof(UnknownToolContentJsonConverter))] public sealed record UnknownToolContent : ToolContent { private readonly string _marker; diff --git a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs index ed04b3436..8fcf2385d 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs @@ -14,11 +14,15 @@ public class ClientOperationBenchmarks : IDisposable { private CannedResponseHandler? _messageHandler; private CannedResponseHandler? _healthHandler; + private CannedResponseHandler? _messageListHandler; private HttpClient? _messageHttpClient; private HttpClient? _healthHttpClient; + private HttpClient? _messageListHttpClient; private OpenCodeClient? _messageClient; private OpenCodeClient? _healthClient; + private OpenCodeClient? _messageListClient; private SessionClient? _session; + private SessionClient? _messageListSession; [GlobalSetup] public async Task SetupAsync() @@ -38,6 +42,12 @@ public async Task SetupAsync() _healthHandler = new CannedResponseHandler(BenchmarkFixtures.HealthBody()); _healthHttpClient = new HttpClient(_healthHandler); _healthClient = new OpenCodeClient(_healthHttpClient, options); + + var page = BenchmarkFixtures.CursorListEnvelope(await BenchmarkFixtures.DeepAssistantMessageAsync().ConfigureAwait(false)); + _messageListHandler = new CannedResponseHandler(page); + _messageListHttpClient = new HttpClient(_messageListHandler); + _messageListClient = new OpenCodeClient(_messageListHttpClient, options); + _messageListSession = _messageListClient.Sessions.GetSessionClient("ses_bench0000000000000000001"); } [Benchmark] @@ -46,6 +56,9 @@ public async Task SetupAsync() [Benchmark] public Task GetHealthAsync() => _healthClient!.GetHealthAsync(); + [Benchmark] + public Task ListMessagesAsync() => _messageListSession!.ListMessagesAsync(); + [GlobalCleanup] public void Cleanup() => Dispose(); @@ -64,9 +77,12 @@ protected virtual void Dispose(bool disposing) _messageClient?.Dispose(); _healthClient?.Dispose(); + _messageListClient?.Dispose(); _messageHttpClient?.Dispose(); _healthHttpClient?.Dispose(); + _messageListHttpClient?.Dispose(); _messageHandler?.Dispose(); _healthHandler?.Dispose(); + _messageListHandler?.Dispose(); } } diff --git a/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs b/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs index ff15e8c0b..efaa3d1f3 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Support/BenchmarkFixtures.cs @@ -16,6 +16,13 @@ public static byte[] DataEnvelope(byte[] payload) return [.. "{\"data\":"u8, .. payload, .. "}"u8]; } + /// Wraps one payload in the cursor-list envelope as a single-item page. + public static byte[] CursorListEnvelope(byte[] payload) + { + ArgumentNullException.ThrowIfNull(payload); + return [.. "{\"data\":["u8, .. payload, .. "],\"cursor\":{\"next\":\"cur_bench_next\"}}"u8]; + } + private static async Task ReadAsync(string name) { var stream = typeof(BenchmarkFixtures).Assembly diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs index b16352a8c..f4643c46b 100644 --- a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -54,6 +54,39 @@ public async Task Deserialize_Should_Preserve_Unknown_Outer_Variant_Byte_For_Byt await Assert.That(roundTrip).IsEqualTo(json); } + [Test] + public async Task Serialize_Should_Reproduce_The_Raw_Document_Through_The_Concrete_Carrier_Type() + { + var json = _fixtures.LoadJson("Serialization.unknown-session-message.json"); + var unknown = (UnknownSessionMessageInfo)_serializer.Deserialize(json); + + var roundTrip = _serializer.Serialize(unknown); + + await Assert.That(roundTrip).IsEqualTo(json); + } + + [Test] + public async Task Deserialize_Should_Preserve_The_Payload_Through_The_Concrete_Carrier_Type() + { + var json = _fixtures.LoadJson("Serialization.unknown-session-message.json"); + + var unknown = _serializer.Deserialize(json); + + await Assert.That(unknown.Type).IsEqualTo("future-message"); + await Assert.That(_serializer.Serialize(unknown)).IsEqualTo(json); + } + + [Test] + public async Task Serialize_Should_Reproduce_The_Raw_Error_Through_The_Concrete_Carrier_Type() + { + const string json = "{\"_tag\":\"BrandNewError\",\"detail\":{\"code\":7}}"; + var unknown = (UnknownOpenCodeError)_serializer.Deserialize(json); + + var roundTrip = _serializer.Serialize(unknown); + + await Assert.That(roundTrip).IsEqualTo(json); + } + [Test] public async Task Deserialize_Should_Create_Nested_Compaction_Variant_Through_The_Outer_Union() { diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 30e2cb59c..e6a425703 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -1101,6 +1101,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownOpenCodeErrorJsonConverter))] public sealed class UnknownOpenCodeError : OpenCode.Sdk.Models.OpenCodeError, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -1109,6 +1110,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("_tag")] public override string Tag { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownPromptFileSourceJsonConverter))] public sealed class UnknownPromptFileSource : OpenCode.Sdk.Models.PromptFileSource, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -1117,6 +1119,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionForkBoundaryJsonConverter))] public sealed class UnknownSessionForkBoundary : OpenCode.Sdk.Models.SessionForkBoundary, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -1125,6 +1128,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageAssistantContentJsonConverter))] public sealed class UnknownSessionMessageAssistantContent : OpenCode.Sdk.Models.SessionMessageAssistantContent, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -1133,6 +1137,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageCompactionJsonConverter))] public sealed class UnknownSessionMessageCompaction : OpenCode.Sdk.Models.SessionMessageCompaction, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -1143,6 +1148,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageInfoJsonConverter))] public sealed class UnknownSessionMessageInfo : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -1151,6 +1157,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageToolStateJsonConverter))] public sealed class UnknownSessionMessageToolState : OpenCode.Sdk.Models.SessionMessageToolState, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] @@ -1159,6 +1166,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("status")] public override string Status { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownToolContentJsonConverter))] public sealed class UnknownToolContent : OpenCode.Sdk.Models.ToolContent, System.IEquatable { [System.Text.Json.Serialization.JsonConstructor] diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt index 41e320106..8e036e577 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt @@ -24,11 +24,13 @@ public abstract record ExampleEvent using System; using System.Text.Json; using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Preserves an unknown example event payload. /// +[JsonConverter(typeof(UnknownExampleEventJsonConverter))] public sealed record UnknownExampleEvent : ExampleEvent { private readonly string _marker; @@ -114,3 +116,41 @@ internal sealed class ExampleEventJsonConverter : JsonConverter JsonSerializer.Serialize(writer, value, typeInfo); } } + +// File: Internal/Serialization/UnknownExampleEventJsonConverter.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.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +internal sealed class UnknownExampleEventJsonConverter : JsonConverter +{ + public override UnknownExampleEvent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + throw new JsonException("The ExampleEvent payload must be a JSON object."); + if (!payload.TryGetProperty("type", out var markerElement)) + throw new JsonException("The ExampleEvent payload must contain 'type'."); + if (markerElement.ValueKind != JsonValueKind.String) + throw new JsonException("The 'type' marker must be a string."); + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + throw new JsonException("The 'type' marker must be a non-empty string."); + return new UnknownExampleEvent(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownExampleEvent value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs index 4202d690e..dd5927e5b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs @@ -12,12 +12,13 @@ internal static class UnionEmitter public static IReadOnlyList Emit(IReadOnlyList unions) { ArgumentNullException.ThrowIfNull(unions); - var result = new List(unions.Count * 3); + var result = new List(unions.Count * 4); foreach (var union in unions.OrderBy(static union => union.Name, StringComparer.Ordinal)) { result.Add(EmitBase(union)); result.Add(EmitUnknown(union)); result.Add(EmitConverter(union)); + result.Add(EmitCarrierConverter(union)); } return Array.AsReadOnly([.. result]); @@ -74,6 +75,10 @@ private static GeneratedSource EmitUnknown(UnionPlan union) } members.Add(EmitUnknownPayloadProperty()); + + // The concrete-type converter keeps consumer serialization of the carrier itself + // reproducing the preserved document; without it, source-generated metadata would + // write the carrier as an ordinary record. var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), union.UnknownTypeName) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), @@ -82,15 +87,102 @@ private static GeneratedSource EmitUnknown(UnionPlan union) .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed(union.Name))))) + .AddAttributeLists(EmissionSyntax.Attribute( + "JsonConverter", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression( + TypeSyntaxEmitter.EmitNamed($"{union.UnknownTypeName}JsonConverter"))))) .WithMembers(SyntaxFactory.List(members)) .WithLeadingTrivia(EmissionSyntax.Documentation($"Preserves an unknown {DisplayName(union.Name)} payload.")); var usingNames = union.MarkerKind is LiteralKind.String - ? new[] { "System", "System.Text.Json", "System.Text.Json.Serialization", } - : ["System.Text.Json", "System.Text.Json.Serialization"]; + ? new[] { "System", "System.Text.Json", "System.Text.Json.Serialization", "OpenCode.Sdk.Internal.Serialization", } + : ["System.Text.Json", "System.Text.Json.Serialization", "OpenCode.Sdk.Internal.Serialization"]; var unit = EmissionSyntax.CompilationUnit(union.Namespace, usingNames, [declaration]); return EmissionSyntax.CreateSource($"Models/{union.UnknownTypeName}.cs", unit); } + /// + /// The carrier's own converter: reading reproduces the base converter's fallback arm, + /// writing replays the preserved document. + /// + private static GeneratedSource EmitCarrierConverter(UnionPlan union) + { + var converterName = $"{union.UnknownTypeName}JsonConverter"; + var readStatements = new List(); + readStatements.AddRange(EmissionSyntax.ArgumentNullGuard("typeToConvert")); + readStatements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); + readStatements.AddRange( + [ + EmitPayloadDocument(), + Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")), + EmitObjectPayloadCheck(union), + EmitMarkerPresenceCheck(union), + ]); + readStatements.AddRange(EmitMarkerRead(union)); + readStatements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName(union.UnknownTypeName)) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("marker")), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")), + ]))))); + var read = SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(union.UnknownTypeName), "Read") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("reader")) + .WithType(SyntaxFactory.IdentifierName("Utf8JsonReader")) + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.RefKeyword))), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("typeToConvert")).WithType(SyntaxFactory.IdentifierName("Type")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")), + ]))) + .WithBody(SyntaxFactory.Block(readStatements)); + + var writeStatements = new List(); + writeStatements.AddRange(EmissionSyntax.ArgumentNullGuard("writer")); + writeStatements.AddRange(EmissionSyntax.ArgumentNullGuard("value")); + writeStatements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); + writeStatements.Add(SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("value"), "Payload"), + "WriteTo"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("writer"))))); + var write = SyntaxFactory.MethodDeclaration( + SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword)), + "Write") + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword), + SyntaxFactory.Token(SyntaxKind.OverrideKeyword))) + .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Parameter(SyntaxFactory.Identifier("writer")).WithType(SyntaxFactory.IdentifierName("Utf8JsonWriter")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("value")).WithType(TypeSyntaxEmitter.EmitNamed(union.UnknownTypeName)), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")), + ]))) + .WithBody(SyntaxFactory.Block(writeStatements)); + + var declaration = SyntaxFactory.ClassDeclaration(converterName) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.InternalKeyword), + SyntaxFactory.Token(SyntaxKind.SealedKeyword))) + .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.Generic( + "JsonConverter", + TypeSyntaxEmitter.EmitNamed(union.UnknownTypeName)))))) + .WithMembers(SyntaxFactory.List([read, write])); + var unit = EmissionSyntax.CompilationUnit( + "OpenCode.Sdk.Internal.Serialization", + [ + "OpenCode.Sdk.Models", + "System", + "System.Text.Json", + "System.Text.Json.Serialization", + ], + [declaration]); + return EmissionSyntax.CreateSource($"Internal/Serialization/{converterName}.cs", unit); + } + private static FieldDeclarationSyntax EmitUnknownMarkerField(TypeSyntax markerType) => SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration(markerType) .WithVariables(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("_marker")))) From be6f63a412b7027be5298957a54c9d7075216dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 03:30:29 +0300 Subject: [PATCH 15/47] fix(generator): land the fail-closed walls batch (#21) Seven latent walls close ahead of the surfaces that would trip them: numeric literals refuse at binding instead of mapping to string (F08); nested marked unions may spell their branches with oneOf (F09); a tag owned by two closure schemas refuses before it can poison the converter dispatch map (F10); wire parameters landing on emitted signature names (options, requestOptions, request, pipeline, cancellationToken) fail at bind, never as emitted compile errors (F11); an out-of-range curation placement is a loud curation error instead of a silently dropped group (F13); repeated path-template tokens refuse at ingestion since builders substitute each token exactly once (F14); and generated client, response, and options names collide against the model namespace and the hand-written spine, not just each other (F15). The P2 envelope fold landed with the emitter batch. Sandbox now demos create -> list -> get -> messages. --- tests/OpenCode.Sdk.Sandbox/Program.cs | 72 ++++++------ .../Properties/launchSettings.json | 4 +- tests/OpenCode.Sdk.Sandbox/README.md | 20 +--- .../Binding/OperationPlanBinderTests.cs | 52 +++++++++ .../Generator/Binding/SpecBinderTests.cs | 107 ++++++++++++++++++ .../Generator/Binding/CurationValidator.cs | 8 ++ .../Generator/Binding/OperationPlanBinder.cs | 55 ++++++++- .../Generator/Binding/SchemaPlanBinder.cs | 22 +++- .../Projection/PathTemplateValidator.cs | 6 +- .../Ingestion/Projection/UnionNormalizer.cs | 14 ++- 10 files changed, 294 insertions(+), 66 deletions(-) diff --git a/tests/OpenCode.Sdk.Sandbox/Program.cs b/tests/OpenCode.Sdk.Sandbox/Program.cs index 2e0c1711f..8bcf4569c 100644 --- a/tests/OpenCode.Sdk.Sandbox/Program.cs +++ b/tests/OpenCode.Sdk.Sandbox/Program.cs @@ -8,7 +8,7 @@ await Console.Error.WriteLineAsync( "Set OPENCODE_SANDBOX_ENDPOINT to an absolute server endpoint; the launchSettings.json profile prefills it.").ConfigureAwait(false); await Console.Error.WriteLineAsync( - "Optional: OPENCODE_SERVER_PASSWORD (read by the SDK itself), OPENCODE_SANDBOX_SESSION_ID, OPENCODE_SANDBOX_MESSAGE_ID.").ConfigureAwait(false); + "Optional: OPENCODE_SERVER_PASSWORD (read by the SDK itself).").ConfigureAwait(false); return 1; } @@ -18,51 +18,45 @@ await Console.Error.WriteLineAsync( var health = await client.GetHealthAsync().ConfigureAwait(false); Console.WriteLine(string.Create( CultureInfo.InvariantCulture, - $"health: status={health.Status} healthy={health.Health.Healthy} version={health.Health.Version} pid={health.Health.Pid}")); + $"health: status={health.Status} healthy={health.Health.Healthy} version={health.Health.Version} pid={health.Health.Pid}")); -var sessionId = Environment.GetEnvironmentVariable("OPENCODE_SANDBOX_SESSION_ID"); -var messageId = Environment.GetEnvironmentVariable("OPENCODE_SANDBOX_MESSAGE_ID"); -if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(messageId)) +var created = await client.Sessions.CreateSessionAsync(new SessionCreateRequest { - await Console.Out.WriteLineAsync("message: skipped (set OPENCODE_SANDBOX_SESSION_ID and OPENCODE_SANDBOX_MESSAGE_ID to fetch one)").ConfigureAwait(false); - return 0; -} + Title = "sdk breadth demo", +}).ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"create: status={created.Status} id={created.Session.Id} title={created.Session.Title}")); -var message = await client.Sessions.GetSessionClient(sessionId).GetMessageAsync(messageId).ConfigureAwait(false); -Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"message: status={message.Status} type={message.Message.GetType().Name}")); -if (message.Message is SessionMessageAssistant assistant) +var page = await client.Sessions.ListSessionsAsync(new SessionListOptions { - Console.WriteLine($" id: {assistant.Id}"); - Console.WriteLine($" agent: {assistant.Agent}"); - Console.WriteLine($" model: {assistant.Model}"); - Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" finish: {assistant.Finish} cost: {assistant.Cost} tokens: {assistant.Tokens}")); - Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $" content: {assistant.Content.Count} part(s)")); - foreach (var part in assistant.Content) - { - var summary = part switch - { - SessionMessageAssistantText text => Excerpt(text.Text), - SessionMessageAssistantReasoning reasoning => Excerpt(reasoning.Text), - UnknownSessionMessageAssistantContent unknown => $"unknown tag '{unknown.Type}'", - _ => Excerpt(part.ToString()), - }; - Console.WriteLine($" [{part.Type}] {summary}"); - } -} -else + Limit = 3, + Order = ListOrder.Descending, +}).ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"list: status={page.Status} sessions={page.Sessions.Count} cursor.next={page.Cursor.Next ?? ""}")); +foreach (var session in page.Sessions) { - Console.WriteLine($" payload: {Excerpt(message.Message.ToString())}"); + Console.WriteLine($" {session.Id} {session.Title}"); } -return 0; +var handle = client.Sessions.GetSessionClient(created.Session.Id); +var fetched = await handle.GetSessionAsync().ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"get: status={fetched.Status} id={fetched.Session.Id} directory={fetched.Session.Location.Directory}")); -static string Excerpt(string? value) +var messages = await handle.ListMessagesAsync(new MessageListOptions { - if (value is null) - { - return ""; - } - - var flattened = value.ReplaceLineEndings(" "); - return flattened.Length <= 160 ? flattened : $"{flattened[..160]}…"; + Limit = 5, +}).ConfigureAwait(false); +Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"messages: status={messages.Status} count={messages.Messages.Count} cursor.next={messages.Cursor.Next ?? ""}")); +foreach (var message in messages.Messages) +{ + Console.WriteLine($" {message.GetType().Name}"); } + +return 0; diff --git a/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json b/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json index 80e9c6f2d..0225cd971 100644 --- a/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json +++ b/tests/OpenCode.Sdk.Sandbox/Properties/launchSettings.json @@ -4,9 +4,7 @@ "commandName": "Project", "environmentVariables": { "OPENCODE_SANDBOX_ENDPOINT": "http://127.0.0.1:4096", - "OPENCODE_SERVER_PASSWORD": "123456", - "OPENCODE_SANDBOX_SESSION_ID": "ses_00e0d1436ffe86YL3QKbrn3Ldy", - "OPENCODE_SANDBOX_MESSAGE_ID": "msg_ffb0e86fa001n28S9q0u7F7JxM" + "OPENCODE_SERVER_PASSWORD": "123456" } } } diff --git a/tests/OpenCode.Sdk.Sandbox/README.md b/tests/OpenCode.Sdk.Sandbox/README.md index 283baac7e..3735a6951 100644 --- a/tests/OpenCode.Sdk.Sandbox/README.md +++ b/tests/OpenCode.Sdk.Sandbox/README.md @@ -12,8 +12,6 @@ Configuration comes from environment variables, prefilled for the IDE by |---|---| | `OPENCODE_SANDBOX_ENDPOINT` | Absolute server endpoint (required) | | `OPENCODE_SERVER_PASSWORD` | Consumed by the SDK's own auth fallback, not by sandbox code | -| `OPENCODE_SANDBOX_SESSION_ID` | Optional; with a message id, fetches one message | -| `OPENCODE_SANDBOX_MESSAGE_ID` | Optional; see above | ## Running @@ -25,21 +23,9 @@ generates and prints a random one): OPENCODE_SERVER_PASSWORD=123456 opencode2 serve --hostname 127.0.0.1 --port 4096 ``` -Then F5 with the `sandbox` profile. The prefilled session/message ids belong to this -machine's local opencode history — replace them when they age out. - -## Finding ids with curl - -Session and message ids come from the pinned surface's list operations -(`v2.session.list`, `v2.message.list` — both still pending SDK breadth): - -```sh -# newest session id -curl -su opencode:123456 "http://127.0.0.1:4096/api/session?limit=1" | jq -r '.data[0].id' - -# first message id of that session -curl -su opencode:123456 "http://127.0.0.1:4096/api/session//message" | jq -r '.data[0].id' -``` +Then F5 with the `sandbox` profile. The program drives the breadth surface end to end: +health, `CreateSessionAsync`, `ListSessionsAsync` (typed page + wire cursor), +`GetSessionAsync` on the created handle, and `ListMessagesAsync`. The project deliberately stays a flat console for now; it grows a Generic Host composition root when the Extensions package (DI) lands. diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index a62aa44a4..66e32a07f 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -713,6 +713,58 @@ public async Task Bind_Should_Refuse_A_Wildcard_Path() await AssertOperationRefusalAsync(document, "v2.health.get", "wildcard"); } + [Test] + public async Task Bind_Should_Refuse_A_Reserved_Parameter_Name() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.item", path: "/api/widget/{options}", configure: operation => operation + .Parameter("options", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("reserved by the emitted method signature", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Response_Type_Shadowing_A_Model() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetStateResponse", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.state", path: "/api/widget-state", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetStateResponse"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.state"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("WidgetStateResponse", StringComparison.Ordinal) + && error.Problem.Contains("response type", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_A_Client_Name_Colliding_With_The_Spine() + { + var document = await BindingTestHost.IngestAsync(GadgetScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.part"), + Curation(Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: "ListCursor", handleParameter: "gadgetID"))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("ListCursor", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Refuse_Colliding_Method_Names_On_A_Client() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index ff12f664d..dfe9a67a4 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -351,6 +351,113 @@ await Assert.That(exception.Errors.Single(static error => error.Category == Bind .Contains("duplicated"); } + [Test] + public async Task Bind_Should_Refuse_A_Numeric_Literal_In_The_Selected_Closure() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("VersionedItem", schema => schema.Type("object") + .Property("version", property => property.Type("number").Raw("enum", "[3]"), required: true)) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("VersionedItem"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("numeric literal", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Bind_A_Nested_Marked_OneOf_Union() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("Alpha", schema => schema.Type("object") + .Property("type", property => property.Type("string").Enum("alpha"), required: true)) + .WithSchema("WrapOne", schema => schema.Type("object") + .Property("type", property => property.Type("string").Enum("wrap"), required: true) + .Property("status", property => property.Type("string").Enum("one"), required: true)) + .WithSchema("WrapTwo", schema => schema.Type("object") + .Property("type", property => property.Type("string").Enum("wrap"), required: true) + .Property("status", property => property.Type("string").Enum("two"), required: true)) + .WithSchema("Wrap", schema => schema.OneOf(one => one.Ref("WrapOne"), two => two.Ref("WrapTwo"))) + .WithSchema("Outer", schema => schema.AnyOf(alpha => alpha.Ref("Alpha"), wrap => wrap.Ref("Wrap"))) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("Outer"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), })); + + var wrap = plan.Unions.Single(static union => union.Name == "Wrap"); + await Assert.That(wrap.MarkerWireName).IsEqualTo("status"); + await Assert.That(wrap.BaseTypeName).IsEqualTo("Outer"); + } + + [Test] + public async Task Bind_Should_Refuse_A_Tag_Owned_By_Two_Closure_Schemas() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("GoneError", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("SharedError"), required: true) + .Property("message", property => property.Type("string"), required: true)) + .WithSchema("LostError", schema => schema.Type("object") + .Property("_tag", property => property.Type("string").Enum("SharedError"), required: true) + .Property("detail", property => property.Type("string"), required: true)) + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ItemInfo")) + .Response(404, "application/json", schema => schema.Ref("GoneError")) + .Response(410, "application/json", schema => schema.Ref("LostError"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }))); + + await Assert.That(exception.Errors.Any(static error => + error.Problem.Contains("multiple error schemas declare tag 'SharedError'", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Unrecognized_Group_Placement() + { + var document = await IngestAsync(SpecScenario.Define(spec => spec + .WithOperation("v2.health.get", configure: operation => operation + .Response(200, "application/json", schema => schema.Type("object") + .Property("data", property => property.Type("string"), required: true))))); + var groups = new Dictionary(StringComparer.Ordinal) + { + ["health"] = new GroupCuration { Placement = (GroupPlacement)7 }, + }; + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.health.get"), + Curation(groups))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("not a recognized group placement", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task Ingest_Should_Refuse_A_Repeated_Path_Token() + { + var scenario = SpecScenario.Define(spec => spec + .WithOperation("v2.health.get", path: "/api/{id}/echo/{id}", configure: operation => operation + .Parameter("id", "path", schema => schema.Type("string"), required: true))); + + var exception = await Assert + .That(async () => _ = await IngestAsync(scenario)) + .Throws(); + + await Assert.That(exception!.Errors.Any(static error => + error.Problem.Contains("must appear exactly once", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Collapse_A_Structurally_Identical_Schema_Alias() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index e3a1cd662..cd4a26b48 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -78,6 +78,14 @@ private static void ValidateGroupShape(string wireName, GroupCuration group, IRe case GroupPlacement.Client when string.IsNullOrWhiteSpace(group.ClientName): errors.Add(BindingErrorCategory.Curation, wireName, "client group must declare clientName"); break; + case not (GroupPlacement.Root or GroupPlacement.Client): + // System.Text.Json admits numeric enum spellings even under the string + // converter, so an out-of-range placement must fail here, not drop silently. + errors.Add( + BindingErrorCategory.Curation, + wireName, + $"placement value '{((int)group.Placement).ToString(System.Globalization.CultureInfo.InvariantCulture)}' is not a recognized group placement"); + break; } if (group.ClientName is not null && !CSharpNamePolicy.IsValidIdentifier(group.ClientName)) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index a6388f183..4b2bfa584 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -15,6 +15,32 @@ internal sealed class OperationPlanBinder /// from /record synthesis; a payload landing on one of them needs a /// curated override. /// + /// Hand-written public spine types in the client namespace; a generated twin would not compile. + private static readonly string[] ReservedSpineTypeNames = + [ + "ErrorBehavior", + "ListCursor", + "ListOptions", + "ListOrder", + "OpenCodeApiException", + "OpenCodeClientOptions", + "OpenCodeException", + "OpenCodeRequestOptions", + "OpenCodeResponse", + "OpenCodeTransportException", + "SessionParentFilter", + ]; + + /// Parameter names the emitted method and route-builder signatures append themselves. + private static readonly string[] ReservedParameterNames = + [ + "cancellationToken", + "options", + "pipeline", + "request", + "requestOptions", + ]; + private static readonly string[] ReservedPayloadNames = [ "Cursor", @@ -61,7 +87,7 @@ public IReadOnlyList Bind(SpecDocument document, IReadOnlyList clients, BindingErrorCollect } } - private void CheckTypeNameCollisions(List clients, List bound, BindingErrorCollector errors) + private void CheckTypeNameCollisions(List clients, List bound, + IReadOnlyDictionary typeNames, BindingErrorCollector errors) { + // Model names shadow across namespaces (consumer CS0104) and hand-written spine + // names collide outright (CS0101), so both seed the owner set. var owners = new HashSet(_comparer); + owners.UnionWith(typeNames.Values); + owners.UnionWith(ReservedSpineTypeNames); foreach (var name in clients.Select(static client => client.Name).Where(name => !owners.Add(name))) { errors.Add(BindingErrorCategory.Naming, name, $"client type name '{name}' collides with another generated type"); @@ -634,6 +665,26 @@ private bool IsListCursorShape(SchemaNode schema) return null; } + // Emitted signatures append these names after bind-time checks; a wire parameter + // landing on one must fail here, never as an emitted compile error. + var reserved = plans + .Select(static plan => plan.Name) + .Where(static name => ReservedParameterNames.Contains(name, StringComparer.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + if (reserved.Length > 0) + { + foreach (var name in reserved) + { + _errors.Add( + BindingErrorCategory.Naming, + _operation.OperationId, + $"parameter name '{name}' is reserved by the emitted method signature"); + } + + return null; + } + return [.. plans.OrderBy(plan => TemplatePosition(plan.WireName))]; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs index e6d0e3e1e..0a332f26a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs @@ -323,6 +323,24 @@ private sealed record ResolvedUnionBranch(string TargetKey, string TypeName, IRe }); } + // A tag owned by two closure types would poison the converter's dispatch map at its + // first use; structurally identical duplicates have the schema-alias escape. + var duplicateTags = variants + .GroupBy(static variant => variant.Tag, StringComparer.Ordinal) + .Where(static group => group.Skip(1).Any()) + .Select(static group => group.Key) + .Order(StringComparer.Ordinal) + .ToArray(); + if (duplicateTags.Length > 0) + { + foreach (var tag in duplicateTags) + { + errors.Add(BindingErrorCategory.Schema, "OpenCodeError", $"multiple error schemas declare tag '{tag}'"); + } + + return null; + } + return new UnionPlan { Name = "OpenCodeError", @@ -477,7 +495,9 @@ public TypePlanBinder(IReadOnlyDictionary graph, IReadOnlyDi { RefNode reference => BindReference(reference, subject, aliases), PrimitiveNode primitive => BindPrimitive(primitive), - LiteralNode literal => Named(literal.Kind is LiteralKind.Boolean ? "bool" : "string"), + LiteralNode { Kind: LiteralKind.Boolean } => Named("bool"), + LiteralNode { Kind: LiteralKind.String } => Named("string"), + LiteralNode => Refuse(subject, "numeric literal schemas are not supported by the emitter"), ArrayNode array => BindArray(array, subject, aliases), DictionaryNode dictionary => BindDictionary(dictionary, subject, aliases), FreeFormObjectNode => DictionaryOf(Named("JsonElement")), diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs index aa5decbfd..a0ab5a711 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/PathTemplateValidator.cs @@ -62,9 +62,11 @@ private static HashSet ReadTokens(string path, string location, Ingestio { errors.Add(location, $"path '{path}' contains a malformed template token"); } - else + else if (!tokens.Add(token)) { - tokens.Add(token); + // Route builders substitute each token exactly once; a repeated placeholder + // would leave raw braces in the built route. + errors.Add(location, $"path token '{token}' must appear exactly once in the template"); } index = close + 1; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs index 3b4673489..3d9dae50a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Ingestion/Projection/UnionNormalizer.cs @@ -188,10 +188,20 @@ private bool ValidateSpecialNumberBranches(IList branches, strin /// private bool IsMarkedUnionReference(IOpenApiSchema source) => source is OpenApiSchemaReference { Target: OpenApiSchema target } - && IsPureAnyOfBranch(target) - && target.AnyOf is { Count: > 1 } nested + && (IsPureAnyOfBranch(target) || IsPureOneOfBranch(target)) + && (target.AnyOf is { Count: > 1 } ? target.AnyOf : target.OneOf) is { Count: > 1 } nested && nested.All(branch => _literalClassifier.FindFirstMarker(branch) is not null); + /// The oneOf twin of ; a nested marked union may use either keyword exclusively. + private static bool IsPureOneOfBranch(OpenApiSchema schema) => + schema is { OneOf.Count: > 0, Type: null, Enum: null, Const: null, Items: null, Properties: null, AdditionalProperties: null } + && schema.AnyOf is not { Count: > 0 } + && schema.AllOf is not { Count: > 0 } + && schema.Required is not { Count: > 0 } + && schema.PatternProperties is not { Count: > 0 } + && schema.AdditionalPropertiesAllowed + && schema is { ContentEncoding: null, ContentMediaType: null, ContentSchema: null }; + private static ProjectedBranch[] DeduplicateReferences(IReadOnlyList branches) { var targets = new HashSet(StringComparer.Ordinal); From b4381170a671cf6f73fa059042b09b62c961a33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 03:33:56 +0300 Subject: [PATCH 16/47] docs: close out the M2 first breadth batch Research log Session 20 records the decisions sealed during execution (options/order/cursor type names, the schemaAliases resolution of the InvalidRequestError1 duplicate with its drift guarantees, the options/requestOptions parameter rule) and the execution notes (concrete verb rules, the P2 numbers, the defect the contract matrix caught). The ROADMAP moves the batch to complete, the plan's checkboxes close, and the consumed 2026-08-14 handover is deleted. --- docs/ROADMAP.md | 23 ++++-- .../handover-prompts/HANDOFF-2026-08-14-1.md | 78 ------------------- docs/research/00-research-log.md | 59 ++++++++++++++ .../2026-08-14-m2-first-breadth-batch.md | 16 ++-- 4 files changed, 83 insertions(+), 93 deletions(-) delete mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2906e2edc..2c61cf3a9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -27,9 +27,19 @@ the blocker set (typed-spine leaks, silent route rewriting) lands on the PR bran issues #19–#25 pinned to the milestone that resolves it — nothing on that list outlives the M series. -M2 is open: the first breadth batch (`session.list` + `session.get` + `session.create` + -`message.list`) executes per `superpowers/plans/2026-08-14-m2-first-breadth-batch.md`, -with its API decisions sealed (research log Session 19). +**M2's first breadth batch is complete** (plan: +`superpowers/plans/2026-08-14-m2-first-breadth-batch.md`; decisions: research log Sessions +19–20). `session.list`, `session.get`, `session.create`, and `message.list` are callable +through their final generated surface — options records over the `ListOptions` seam, +`SessionCreateRequest` bodies through the pipeline's JSON path, cursor-list envelopes with +the shared `ListCursor`, query-composing routes, the `Session.Info` model closure, and the +first 5xx arm. Riders #19 (carrier converters), #21 (fail-closed walls + P2 single-pass +envelopes: `GetMessageAsync` 67.4→56.2 μs, `ListMessagesAsync` baseline 58.1 μs/28.24 KB), +and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `InvalidRequestError1` +duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14 +against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor +round-tripped). Maintainer decisions #20 (password semantics) and #25 (IVT) remain open; +further breadth batches continue M2. ## Milestones @@ -47,10 +57,9 @@ is revisited at each milestone boundary. `superpowers/specs/2026-08-11-production-walking-skeleton-design.md`. 2. **M2 — Breadth batches.** The generation profile grows in vertical operation batches; each batch lands its curation rows, reachable models, operation methods, and contract - tests together. Opens with the review hardening batch (#19 `Unknown*` serialization, - #20 password-semantics decision, #25 IVT decision); the generator fail-closed walls - batch (#21, includes the P2 envelope fold) and the naming/curation wall batch (#22) - land at this boundary. + tests together. The first batch (list/get/create/message-list) is complete, carrying + the #19/#21/#22 hardening riders; decisions #20 (password semantics) and #25 (IVT) + stay before the maintainer. 3. **M3 — Streams.** SSE engine over the v2 stream surface (`v2.event.subscribe`, `v2.session.log` with `after`/`follow`, cursor-paged `v2.message.list`); the v1 durable-stream design does not carry over and is re-derived here. Demo: watching a diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md b/docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md deleted file mode 100644 index b4a9e3746..000000000 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-14-1.md +++ /dev/null @@ -1,78 +0,0 @@ -# M2 Execution Handoff — Task 1 Onward - -Date: 2026-08-14 - -Consume this document against live Git and delete it when the M2 batch branch merges. -It records the state between the M2 opening (plan sealed, committed) and the batch's -execution. The operative document is the plan — this handoff only carries what the plan -and the evergreen docs do not. - -## Onboarding Reading Order - -1. `AGENTS.md`, then ADRs 0003/0004/0005/0007/0008/0009. -2. `docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md` — **the operative - document**: sealed API decisions, boundaries, task list (Task 0 done, Tasks 1–8 open). -3. Research log Session 19 (Q76–Q79 — the sealed M2 decisions with their evidence); - Sessions 17–18 for the v2-retarget and Arc B context. -4. `docs/ROADMAP.md` — status, milestone anchors, the #19–#25 issue queue. - -## Git And Workspace State - -| Item | State | -|---|---| -| `master` | `75af30e` — PR #16 merged: M1 callable client + review blocker fixes + performance suite | -| Branch | `feature/m2-first-breadth-batch` @ `4807bc8` (plan-only commit, pushed) | -| Tree | clean; all six gates green; 571 tests across tools + SDK legs | -| CI | three-OS matrix green on master and on the merged PR | -| Issues | #17 (blockers) and #18 (perf infra) closed by the merge; #19–#25 open, milestone-anchored | -| Perf baseline | issue #18 comment @ `959c4f0`: end-to-end `GetMessageAsync` 67.4 μs / 27.42 KB; union walk alone 52.8 μs / 17.37 KB (~78% of total); `GetHealthAsync` 1.56 μs / 2.08 KB | - -## Next Work - -Plan Task 1: curation rows for the four operations; binder support for optional -nullable query parameters (with the `ListOptions` profile-detection wall) and inline -request-body binding. Then Tasks 2–8 in plan order. The sealed decisions (options/base -shape, `int?` limit, `SessionParentFilter`, `SessionCreateRequest`, `List*`/`Create*` -verb rules) are settled — do not reopen without new evidence. - -## Session Mechanics The Docs Don't Carry - -- **Dev loop:** the maintainer authorizes committing per green increment and pushing - within the loop; red-test-first is demanded; public-API taste decisions are batched to - the maintainer as options with a recommendation (they often counter-question, then - seal). Run all six gates before every commit, chained with `set -o pipefail` and an - explicit ALL-GREEN marker. Do not use `grep -c` inside the chain — it exits 1 on zero - matches and reads as a red gate. -- **Regen ritual:** edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` - → review the git diff of committed output → run tests. Emitter changes turn the three - Verify snapshot tests in `OpenCode.Sdk.Tools.Tests/Snapshots/` red; review the received - diff, then accept received→verified. `generate --verify` must be a byte-identical no-op - before commit. -- **Public API baseline** (`PublicApi.verified.txt`, TFM-shared) will change in M2 — new - public surface is expected; the baseline diff is itself review surface for the - maintainer, never auto-accepted silently. -- **Perf discipline:** perf-touching changes carry before/after BenchmarkDotNet numbers - (`tests/OpenCode.Sdk.Performance.Tests`, `MemoryDiagnoser`); allocation deltas are the - primary regression currency (deterministic), timing secondary. Benchmarks are not a CI - gate. Task 7 adds a `ListMessagesAsync` benchmark once the surface exists. -- **Multi-TFM traps already paid for** (do not rediscover): downlevel legs need - conditional ``/`` items (test csproj has - them since `52a5e3b`); ns2.0's BCL lacks `[NotNullWhen(false)]`, so generated - non-null-proving guards carry an explicit `x is null ||` disjunct (see the union marker - guard); SonarAnalyzer relaxes test rules only on test-framework references, so - benchmark-style projects arbitrate via csproj `NoWarn` + a single-line - `slopwatch-ignore: SW005` comment; the net472 test leg builds only on Windows CI but - compile-probes locally via `-p:TargetFrameworks=net472`. -- **External review pattern:** the maintainer runs reviews outside this harness from - hand-off instruction files (gitignored `.scratchpad/ultra-review-handoff.md` + - `VERIFY-HANDOFF.md` from the M1 cycle — reusable templates with the commit range and a - JSON result contract). Findings come back as scratchpad reports; triage verifies before - trusting, then everything surviving is preserved into GitHub issues (nothing lives only - in scratchpad). - -## Stop Conditions - -Arc B's list carries through the plan's boundaries (binder needing raw OpenAPI, -name-branching emitters, structural unions, ownership ambiguity, TFM breakage, partial -packs). Public-API taste goes to the maintainer. Maintainer decisions #20 (password -semantics) and #25 (IVT) are queued to be put before them alongside M2 execution. diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index 3d09d314c..23d970c80 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1708,3 +1708,62 @@ record; the binder recognizes the wire shape mechanically, never the parameter n a generated `SessionCreateRequest`; the operation parameter is optional — `CreateSessionAsync()` sends an empty JSON body. The `{Subject}{Verb}Request` pattern is the mechanical rule for future bodies. + +# Session 20 — 2026-08-14: M2 first breadth batch execution + +The batch executed per the sealed plan: binder walls opened red-test-first (query options +with the profile-detection wall, request bodies, cursor-list envelopes, merged client +families), the pipeline grew its JSON body path, the naming and fail-closed walls batches +(#22, #21) and the F07 carrier converters (#19) landed, and the four operations were +demonstrated live against `opencode2 serve` v0.0.0-next-17403 (create → list → get → +messages, all typed 200s, wire cursor round-tripped). This entry records the decisions +sealed during execution. + +## Q80: What are the concrete names of the query surface's supporting types? + +**How researched:** batched to the maintainer with previews once the binder was about to +encode names; Stripe (`SessionListOptions`) and the sealed `{Subject}{Verb}Request` rule as +anchors. + +**Decision (maintainer, sealed):** options records follow `{Group}{Verb}Options` +(`SessionListOptions`, `MessageListOptions`), mirroring the request-model rule so one +mechanical family covers both; the shared order enum is the hand-written spine +`ListOrder { Ascending, Descending }` (request-only closed enum — ADR-0009 tolerance does +not apply); the wire cursor surfaces through one hand-written `ListCursor` record on every +list envelope, giving the M3 paginator a single cursor seam. The binder validates the wire +shapes (`asc`/`desc` exactly; `{previous?, next?}` exactly) and fails closed otherwise. + +## Q81: How is upstream's InvalidRequestError1 duplicate resolved? + +**Found:** the plan's recon framed it as a naming dup; execution showed it byte-identical to +`InvalidRequestError` *including the `_tag`*, which trips the per-status duplicate-tag wall +and would poison `OpenCodeError` tag dispatch. Two candidate mechanisms were priced: a +mechanical structural dedup (which needed a novel recorded-output channel to satisfy the +maintainer's drift-visibility requirement) and a curated alias. + +**Decision (maintainer, sealed):** a `schemaAliases` curation section — one row declaring +the duplicate a spelling of its target, validated fail-closed for deep structural identity +and applied as a pure document transform before binding. Drift stays loud through existing +machinery: a deleted source or target orphans the row, a dereferenced source goes dormant +against the profile, and any structural divergence (the tag included) breaks the identity +wall. The binder's own duplicate-tag refusals stay intact for anomalies without an alias. + +## Q82: How do the two options-like parameters coexist on generated methods? + +**Decision (maintainer):** the query record binds to `options` and `OpenCodeRequestOptions` +binds to `requestOptions` on every generated method — the type's own name decides, matching +Stripe's idiom; the pre-release rename of the existing surface rode the PublicApi baseline +review. + +## Execution notes + +- The verb rules (sealed decision 5) concretized as: only the final identifier segment can + be a verb (`create`/`get`/`list` — the C18 structural fix); empty subjects fall back to + the group, pluralized for list operations under a naive fail-closed rule; response type + names fold non-Get verbs; client-placed route members mirror their method names while + root members keep the bare-verb shape (`Health.Get`). +- The P2 single-pass envelope DTOs cut `GetMessageAsync` from 67.4 μs to 56.2 μs with flat + allocations; `ListMessagesAsync` baselines at 58.1 μs / 28.24 KB. +- The contract matrix caught a real defect before any consumer could: the error-path + constructor pushed its forgiven null through the list payload's defensive-copy init + accessor; the copy now passes the forgiven null through uncopied behind the guard. diff --git a/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md index 10a58b1c0..dea5030cd 100644 --- a/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md +++ b/docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md @@ -74,28 +74,28 @@ serialized through the source-generated context. - [x] **0. Decision batch** — the five decisions above, sealed with the maintainer 2026-08-14 (research log Session 19). -- [ ] **1. Curation + binding** — curation rows for the four operations; binder support +- [x] **1. Curation + binding** — curation rows for the four operations; binder support for optional nullable query parameters (fail-closed on any other query shape) and for inline JSON request bodies bound into a generated request model; multi-tag 400 and the 500 arm enter the status-map machinery. -- [ ] **2. Model closure** — regenerate with the `Session.Info` graph, the new error +- [x] **2. Model closure** — regenerate with the `Session.Info` graph, the new error types, and the list envelopes; resolve `InvalidRequestError1` naming mechanically or fail closed into a curated override; API baseline review. -- [ ] **3. Pipeline body path** — `ExecuteAsync` overload (or body-carrying request +- [x] **3. Pipeline body path** — `ExecuteAsync` overload (or body-carrying request shape) serializing through `OpenCodeJsonContext`; `Content-Type: application/json`; covered by pipeline tests (body bytes, header, GET stays body-less). -- [ ] **4. Emitters** — operation methods carrying options records and body models; route +- [x] **4. Emitters** — operation methods carrying options records and body models; route builders composing escaped query strings; list-envelope response adapters; the generator fail-closed walls batch (#21) lands here, where breadth first stresses those walls, with P2's single-pass `{data}` DTO folded into the envelope design. -- [ ] **5. Naming batch (#22)** — `List*`/`Create*` rules + C18 fix; verify C17/C19/C20 +- [x] **5. Naming batch (#22)** — `List*`/`Create*` rules + C18 fix; verify C17/C19/C20 against the wall and fix what holds. -- [ ] **6. Hardening riders** — F07 concrete `Unknown*` converter (#19); decisions #20 +- [x] **6. Hardening riders** — F07 concrete `Unknown*` converter (#19); decisions #20 (password semantics) and #25 (IVT) to the maintainer alongside task 0. -- [ ] **7. Contract matrix + perf** — contract tests over the four operations (success, +- [x] **7. Contract matrix + perf** — contract tests over the four operations (success, every declared error status, cursor round-trip, undeclared-2xx, empty-marker); extend `ClientOperationBenchmarks` with a `ListMessagesAsync` benchmark once the surface exists. -- [ ] **8. Close-out** — live sandbox demo (create → list → get → messages) against +- [x] **8. Close-out** — live sandbox demo (create → list → get → messages) against `opencode2 serve`; ROADMAP status update; research-log entries for decisions sealed during execution. From 2be2d0f8bc846fad9cda799f292dc12cc20ef947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 13:12:48 +0300 Subject: [PATCH 17/47] fix(sdk): refuse a blank explicit password (#20) Null stays the unset spelling that reaches the OPENCODE_SERVER_PASSWORD fallback; an explicitly empty or whitespace password fails loudly with ArgumentException at construction instead of silently bypassing the fallback and traveling as a real Basic credential. The server always generates a password, so an empty-credential mode has no upstream meaning to model. Property documentation states the contract. --- OpenCode.slnx | 2 ++ src/OpenCode.Sdk/Internal/Pipeline.cs | 10 ++++++++++ src/OpenCode.Sdk/OpenCodeClientOptions.cs | 6 ++++-- tests/OpenCode.Sdk.Tests/PipelineTests.cs | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/OpenCode.slnx b/OpenCode.slnx index 2254cb7a1..98599005b 100644 --- a/OpenCode.slnx +++ b/OpenCode.slnx @@ -22,6 +22,8 @@ + + diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 4fa62e5f4..214e2a349 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -26,6 +26,16 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, Uri endpoint, stri ArgumentNullException.ThrowIfNull(endpoint); ArgumentNullException.ThrowIfNull(environment); + // An explicitly blank password has no upstream meaning (the server always generates + // one), so it fails loudly instead of inventing an empty-credential mode; null stays + // the unset spelling that reaches the environment fallback below. + if (password is not null && string.IsNullOrWhiteSpace(password)) + { + throw new ArgumentException( + "An explicit password cannot be empty or whitespace; leave it unset to use the OPENCODE_SERVER_PASSWORD fallback.", + nameof(password)); + } + _httpClient = httpClient; _ownsHttpClient = ownsHttpClient; _endpointBase = EndpointPolicy.Normalize(endpoint); diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index b6524f2d9..99d0df28e 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -12,8 +12,10 @@ public sealed class OpenCodeClientOptions /// /// Gets or sets the HTTP basic-authentication password for the opencode user. - /// When unset, the OPENCODE_SERVER_PASSWORD environment variable is read once at - /// client construction. + /// When unset (), the OPENCODE_SERVER_PASSWORD environment + /// variable is read once at client construction; an empty or whitespace value is refused + /// with — the server always generates a password, so an + /// explicitly blank one has no meaning. /// public string? Password { get; set; } } diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index afcd0fcd8..9db2770a4 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -72,6 +72,20 @@ public async Task ExecuteAsync_Should_Resolve_The_Environment_Password_Once_At_C _ = environment.Received(1).GetEnvironmentVariable(PasswordVariable); } + [Test] + [Arguments("")] + [Arguments(" ")] + [Arguments("\t")] + public async Task Pipeline_Should_Refuse_A_Blank_Explicit_Password(string password) + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient, password: password)); + + await Assert.That(exception.Message).Contains("OPENCODE_SERVER_PASSWORD"); + } + [Test] public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Resolves() { From 39c46a918499eb6673c910254a068b47879dae5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 13:19:22 +0300 Subject: [PATCH 18/47] docs: seal the alignment batch and hand it to the next session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research log Session 20 gains the review's three sealed decisions: operation inputs unify on {Group}{Subject}{Verb}Request across query and body (Q83, revising Q76's naming half on the ecosystem survey — no surveyed SDK splits the suffix by verb), the shipped SDK slices by client family with flat public namespaces and mirrored test layout (Q84, canonical wording now in coding-style §4 with the IDE0130 arbitration), and the Extensions package rises in parallel with M2 breadth instead of waiting for M6 (Q85). The #20 and #25 closures are recorded, the ROADMAP carries the alignment-batch queue, and HANDOFF-2026-08-14-2 hands the execution to the next session. --- docs/ROADMAP.md | 20 +-- .../handover-prompts/HANDOFF-2026-08-14-2.md | 132 ++++++++++++++++++ docs/engineering/coding-style.md | 15 +- docs/research/00-research-log.md | 50 +++++++ 4 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2c61cf3a9..bec9239e0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,8 +38,11 @@ envelopes: `GetMessageAsync` 67.4→56.2 μs, `ListMessagesAsync` baseline 58.1 and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `InvalidRequestError1` duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14 against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor -round-tripped). Maintainer decisions #20 (password semantics) and #25 (IVT) remain open; -further breadth batches continue M2. +round-tripped). The #20 decision landed (blank explicit passwords refuse; `null` keeps the +environment fallback) and #25 closed keep. Next: the alignment batch — uniform `*Request` +naming (Q83), the feature-slice layout migration (Q84), and the Extensions bring-up (Q85) +— handed to the next session (`agents/handover-prompts/HANDOFF-2026-08-14-2.md`); further +breadth batches follow. ## Milestones @@ -57,9 +60,10 @@ is revisited at each milestone boundary. `superpowers/specs/2026-08-11-production-walking-skeleton-design.md`. 2. **M2 — Breadth batches.** The generation profile grows in vertical operation batches; each batch lands its curation rows, reachable models, operation methods, and contract - tests together. The first batch (list/get/create/message-list) is complete, carrying - the #19/#21/#22 hardening riders; decisions #20 (password semantics) and #25 (IVT) - stay before the maintainer. + tests together. The first batch (list/get/create/message-list) is complete with every + review rider resolved (#19–#22, #20, #25); the alignment batch (uniform `*Request`, + feature-slice layout, Extensions bring-up — research log Q83–Q85) precedes further + breadth. The Extensions package rises in parallel with these batches from here on. 3. **M3 — Streams.** SSE engine over the v2 stream surface (`v2.event.subscribe`, `v2.session.log` with `after`/`follow`, cursor-paged `v2.message.list`); the v1 durable-stream design does not carry over and is re-derived here. Demo: watching a @@ -73,9 +77,9 @@ is revisited at each milestone boundary. platform detail: research doc 15.) 5. **M5 — Full surface.** Complete generation profile over the protocol surface, exclusion fingerprints (ADR-0008), packaging unblocked. -6. **M6 — Operational closure.** `refresh-spec`, Extensions DI breadth, - retry/telemetry/hooks, quarantine lane, nightly canary (the performance suite joins - it); durable decisions distill into ADRs and the `superpowers/` documents retire. Any +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 hygiene-sweep leftovers (#24) are resolved here — nothing from the review queue survives the M series. diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md b/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md new file mode 100644 index 000000000..312de9661 --- /dev/null +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md @@ -0,0 +1,132 @@ +# Alignment Batch Handoff — uniform *Request, feature-slice layout, Extensions bring-up + +Date: 2026-08-14 + +Consume this document against live Git and delete it when the alignment batch ships. It +executes three decisions the maintainer sealed while reviewing the M2 first breadth batch +(research log Session 20, Q83–Q85). The decisions are settled — do not reopen without new +evidence; the *mechanics* below are guidance, not law. + +## Onboarding Reading Order + +1. `AGENTS.md`, then ADRs 0003/0004/0005/0008. +2. Research log Session 20 — Q83 (uniform `*Request`), Q84 (layout), Q85 (Extensions), + and the Q76→Q83 revision context in Session 19. +3. `docs/engineering/coding-style.md` §4 — the recorded layout rule this batch executes. +4. `docs/ROADMAP.md` — status and queue. + +## Git And Workspace State + +| Item | State | +|---|---| +| Branch | `feature/m2-first-breadth-batch` @ `2be2d0f`+ (breadth batch complete, pushed) | +| Tree | clean; all six gates green; 787 tests | +| Issues | #19/#21/#22 land with the branch's PR merge (reference them in the PR body); #20/#25 closed 2026-08-14 | +| Where this batch lands | continue on the breadth branch if its PR has not merged, else a fresh branch off `master` — maintainer's call at session start | + +## Task 1 — Uniform `*Request` rename (Q83) + +Every operation input is `{Group}{Subject}{Verb}Request`; the Options family retires. + +- **Hand-written spine:** `ListOptions.cs` → `ListRequest.cs` (abstract record + `ListRequest`, same Limit/Order/Cursor trio — the M3 paginator seam keeps its shape + under the new name). `ListOrder`, `ListCursor`, `SessionParentFilter` unchanged. +- **Generator:** `OperationNamePolicy.OptionsTypeName` retires; query records derive + through `RequestTypeName`. **Deliberate consequence:** an operation declaring both a + body and query parameters derives the same type name twice and fails the existing + collision wall — that is the intended fail-closed state until a merged-Request design + (one Request carrying both, AWS-marshalling style) is sealed. Record that in the wall's + refusal test. +- **Internal plan/emitter vocabulary** (`OperationOptionsPlan`, `OptionsPropertyPlan`, + `OptionsEmitter`, `OperationPlan.Options`, `QueryValueKind` stays) may rename to + `Query*`/`*Request*` for coherence — internal taste; pick one scheme, be consistent. +- **Signatures:** method and route-builder parameter `options` → `request` + (`ListSessionsAsync(SessionListRequest? request = null, OpenCodeRequestOptions? + requestOptions = null, …)`); `requestOptions` unchanged. Update + `ReservedParameterNames` to the emitted reality (`request`, `requestOptions`, + `cancellationToken`, `pipeline`) — `options` leaves the set. +- **Fallout to update in the same sweep:** regen (SessionListOptions→SessionListRequest, + MessageListOptions→MessageListRequest), all eight emitter snapshots re-verify, the + PublicApi baseline (rename-only diff — review it as such), contract tests, the + `ListMessagesAsync` benchmark, the sandbox program, ROADMAP wording ("`ListOptions` + seam" → `ListRequest`). The research log stays untouched (dated history). + +## Task 2 — Feature-slice layout migration (Q84) + +Target tree (namespaces stay `OpenCode.Sdk` / `OpenCode.Sdk.Models` — folders are +placement, not naming): + +``` +src/OpenCode.Sdk/ + OpenCodeClient.cs OpenCodeClientOptions.cs OpenCodeRequestOptions.cs + OpenCodeResponse.cs OpenCodeException.cs OpenCodeApiException.cs + OpenCodeTransportException.cs ErrorBehavior.cs OpenCodeRoutes.cs + Pagination/ ListRequest.cs ListOrder.cs ListCursor.cs + Sessions/ SessionsClient.cs SessionClient.cs SessionParentFilter.cs + SessionListRequest.cs MessageListRequest.cs Session*Response.cs + MessageListResponse.cs SessionMessageResponse.cs + Health/ HealthResponse.cs + Models/ (unchanged) + Internal/ (unchanged) +``` + +- **IDE0130 arbitration:** flat public namespaces over feature folders trip + folder-matches-namespace. Arbitrate per the standing pattern (`.editorconfig` §12 + comment naming the winner), scoped to `src/OpenCode.Sdk` — do not relax the rule for + tools or tests. +- **Emitters:** `GeneratedSource.RelativePath` gains the feature folder. The mapping is + mechanical and name-blind: family files (clients, envelopes, request records) go under + `{RouteContainerName}/`; the root client, `OpenCodeRoutes`, and the registry keep their + current homes. Hand-written files move with `git mv`. +- **Writer allowlist** (`GenerationWriter` source-path check) must learn the family + folders — extend it fail-closed (derive admitted folders from the plan's container + names rather than an open glob). Manifest paths churn once; stale-source deletion + removes the old paths in the same regen. +- **Tests mirror:** reorganize `OpenCode.Sdk.Tests` folders to mirror the new tree + (contract tests under `Sessions/`, spine tests under `Pagination/` etc.); test + namespaces stay flat, same arbitration. `Tools.Tests` already mirrors `tools/`. + +## Task 3 — Extensions bring-up (Q85) + +`src/OpenCode.Sdk.Extensions` is an empty shell today. + +- **Scope:** `AddOpenCode` overloads mirroring the three client constructors + (`(Uri)`, `(Uri, Action?)`, BYO-`HttpClient`), registering the + root `OpenCodeClient` plus sub-client registrations + (`services.AddSingleton(sp => sp.GetRequiredService().Sessions)`) so a + consumer resolves `SessionsClient` directly. Root ownership/dispose stays with the + container's singleton root. +- **Hand-written first:** one client family exists; generator-emitted registrations + become worthwhile when families multiply — leave a research-log note, not code. +- **Dependency policy:** research doc 06 governs what the Extensions package may + reference — read it before adding `Microsoft.Extensions.*` packages, and check whether + `IHttpClientFactory` integration is in or out of the first cut (recommendation: out; + additive later). +- **Tests:** a new `OpenCode.Sdk.Extensions.Tests` project (mirroring layout, TFMs per + the Extensions csproj); cover each overload's composition and the sub-client + resolution path. + +## Session Mechanics The Docs Don't Carry + +- Dev loop: all six gates before every commit, chained under `set -o pipefail` with an + explicit ALL-GREEN marker; commit per green increment; push on the working branch is + authorized; red-test-first is demanded. +- **Decisions to the maintainer go as plain text in chat — never AskUserQuestion + dialogs** (option-dialog annotations have been lost in transit before; the maintainer + rejected the flow). Batch them with a recommendation; they often counter-question, + then seal. +- Regen ritual: edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` → + review the git diff of committed output → snapshot received→verified after review → + `generate --verify` must be a byte-identical no-op before commit. +- The PublicApi baseline diff is maintainer review surface — for this batch it should be + renames and (for Extensions) a new assembly baseline only; anything else is a defect. +- Perf: the rename is perf-neutral; do not re-run benchmarks for it. Extensions adds no + hot path. + +## Stop Conditions + +The Arc B list carries (binder needing raw OpenAPI, name-branching emitters, structural +unions, ownership ambiguity, TFM breakage, partial packs), plus: a selected operation +carrying both a request body and query parameters (design the merged Request +deliberately before admitting it), and any Extensions dependency outside research doc +06's policy. diff --git a/docs/engineering/coding-style.md b/docs/engineering/coding-style.md index 528ead19e..1d2c53b61 100644 --- a/docs/engineering/coding-style.md +++ b/docs/engineering/coding-style.md @@ -119,8 +119,19 @@ for a pile of static steps. folder) for data shapes, `Abstractions/` for its seams, implementation classes at the slice root. Similar things sit together; a reader predicts where a type lives before opening the tree. -- File = type (MA0048) and folder = namespace (IDE0130) are already mechanical; this - document adds the *placement* discipline analyzers cannot see. +- **The shipped SDK slices by client family with flat public namespaces** (maintainer, + 2026-08-14): client families are folders (`Sessions/`, `Health/`), the pagination + spine sits under `Pagination/`, wire models under `Models/`, runtime internals under + `Internal/`, and the root client with the response/exception spine at the project + root. Public namespaces stay `OpenCode.Sdk` and `OpenCode.Sdk.Models` — a namespace + is API surface, folders are placement (Stripe/Azure precedent) — so IDE0130's + folder-matches-namespace rule is arbitrated for the SDK's public folders through the + standing per-rule pattern. +- **A test project mirrors the layout of the project under test**: the folder path of a + SUT predicts the folder path of its tests. +- File = type (MA0048) is mechanical everywhere; folder = namespace (IDE0130) is + mechanical outside the SDK's arbitrated public folders. This document adds the + *placement* discipline analyzers cannot see. - **Split before it hurts:** a class approaching a few hundred lines, or a folder flattening into dozens of siblings, is a slice asking to be verticalized. Growth is handled by extraction and sub-foldering at the moment of pressure, not by a rewrite diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index 23d970c80..a341d0543 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1767,3 +1767,53 @@ review. - The contract matrix caught a real defect before any consumer could: the error-path constructor pushed its forgiven null through the list payload's defensive-copy init accessor; the copy now passes the forgiven null through uncopied behind the guard. + +## Q83: Do operation inputs stay split across *Request and *Options? + +**How researched:** maintainer review question (AWS-style Request/Response symmetry); +ecosystem survey — AWS and Google Cloud use a uniform `{Operation}Request` for every +operation regardless of wire placement (query vs body is a marshalling detail), Stripe and +OpenAI use a uniform `*Options` (POST bodies included), Azure's options bag carries only +optional inputs; **no surveyed SDK splits the suffix by verb** as Q76's dichotomy did. + +**Found:** the sealed dichotomy had married halves of two different families, and the +verb→suffix mapping is unstable — a future query-carrying POST would demand two input bags +per call where the uniform-Request shape carries one. + +**Decision (maintainer, sealed):** uniform `*Request`, revising Q76's naming half. Query +records rename (`SessionListRequest`, `MessageListRequest`, base `ListRequest`); the body +models already carry the name; the method and route-builder parameter becomes `request`. +The profile-detection wall and the M3 paginator seam carry over under the new name. An +operation declaring both a body and query parameters would mechanically derive one type +name twice and fail the existing collision wall — deliberate, until a merged-Request +design (AWS marshalling style) is sealed. Execution: next session +(`agents/handover-prompts/HANDOFF-2026-08-14-2.md`). + +## Q84: How does the shipped SDK lay out? + +**Decision (maintainer, sealed):** vertical feature slices with flat public namespaces — +client families as folders (`Sessions/`, `Health/`), the pagination spine under +`Pagination/`, `Models/` and `Internal/` unchanged, the root client and response/exception +spine at the project root. Public namespaces stay `OpenCode.Sdk` and `OpenCode.Sdk.Models`: +a namespace is API surface, folders are placement (Stripe/Azure precedent). Test projects +mirror the layout of the project under test. IDE0130's folder-matches-namespace rule is +arbitrated for the SDK's public folders per the standing per-rule pattern. Canonical +wording: `engineering/coding-style.md` §4. Execution: next session. + +## Q85: When does the Extensions package rise? + +**Decision (maintainer, sealed):** in parallel with M2 breadth instead of waiting for M6 — +an `AddOpenCode` bring-up (overloads mirroring the three client constructors, root plus +sub-client registrations so a consumer can resolve `SessionsClient` directly) lands as its +own batch; the ROADMAP moves "Extensions DI breadth" out of M6 accordingly. + +## Review decisions closed with the batch + +- **#20 (password semantics)** sealed as recommended and landed (`2be2d0f`): `null` = unset + with the environment fallback; empty or whitespace refused with `ArgumentException` — an + explicitly blank credential has no upstream meaning; `""` = explicit no-auth stays an + additive door if upstream ever ships passwordless serve. +- **#25 (DynamicProxyGenAssembly2 IVT)** closed keep: the friend grant is the standard + NSubstitute mechanism over internal seams and a recorded solved-once decision; internals + are not a security boundary. The recorded alternative (hand-written fake replacing the + grant) applies only if encapsulation purity is ever preferred. From 234a299909b0ea87f65e5dfbe8828c9076a3b6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 13:27:08 +0300 Subject: [PATCH 19/47] docs(agents): widen the handoff to the full M2 continuation arc The next session's handover now carries the whole sequence instead of the alignment batch alone: comprehensive onboarding order, the PR #26 state, the alignment batch landing on the open PR, PR finalization, the external multi-agent review cycle with its triage protocol, merge, and the M3 pickup (plan-first convention, scope pointers, and the sanctioned spec-pin refresh at the boundary). --- .../handover-prompts/HANDOFF-2026-08-14-2.md | 214 ++++++++++-------- 1 file changed, 122 insertions(+), 92 deletions(-) diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md b/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md index 312de9661..555191338 100644 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md @@ -1,60 +1,63 @@ -# Alignment Batch Handoff — uniform *Request, feature-slice layout, Extensions bring-up +# Session Handoff — M2 continuation: alignment batch, PR #26 review cycle, M3 pickup Date: 2026-08-14 -Consume this document against live Git and delete it when the alignment batch ships. It -executes three decisions the maintainer sealed while reviewing the M2 first breadth batch -(research log Session 20, Q83–Q85). The decisions are settled — do not reopen without new -evidence; the *mechanics* below are guidance, not law. +Consume this document against live Git; delete it when M3 opens with its own plan. It +carries the full continuation arc after the M2 first breadth batch: the alignment batch +lands on the open PR, the PR is finalized and review-cycled, merged, and then M3 is +picked up. The three alignment decisions are sealed (research log Session 20, Q83–Q85) — +do not reopen without new evidence; the *mechanics* below are guidance, not law. ## Onboarding Reading Order -1. `AGENTS.md`, then ADRs 0003/0004/0005/0008. -2. Research log Session 20 — Q83 (uniform `*Request`), Q84 (layout), Q85 (Extensions), - and the Q76→Q83 revision context in Session 19. -3. `docs/engineering/coding-style.md` §4 — the recorded layout rule this batch executes. -4. `docs/ROADMAP.md` — status and queue. +1. `AGENTS.md`, then every ADR (0001–0009); 0003/0004/0005/0008 are load-bearing for + this work. +2. Research log Sessions 19–20 — the sealed M2 decisions (Q76–Q82), their revision and + the alignment seals (Q83–Q85), and the execution notes. +3. `docs/engineering/coding-style.md` (§4 carries the recorded layout rule this batch + executes) and `docs/engineering/testing-style.md`. +4. `docs/ROADMAP.md` — status, milestone anchors, open questions. +5. The consumed plan `docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md` for + batch context (checkboxes closed; historical). -## Git And Workspace State +## Git, Workspace, And PR State | Item | State | |---|---| -| Branch | `feature/m2-first-breadth-batch` @ `2be2d0f`+ (breadth batch complete, pushed) | +| Branch | `feature/m2-first-breadth-batch` @ `39c46a9`+ (breadth batch complete, pushed) | +| PR | **#26** open against `master`; body carries the batch summary, perf numbers, live-demo transcript, review guidance, and `Closes #19/#21/#22` | | Tree | clean; all six gates green; 787 tests | -| Issues | #19/#21/#22 land with the branch's PR merge (reference them in the PR body); #20/#25 closed 2026-08-14 | -| Where this batch lands | continue on the breadth branch if its PR has not merged, else a fresh branch off `master` — maintainer's call at session start | - -## Task 1 — Uniform `*Request` rename (Q83) - -Every operation input is `{Group}{Subject}{Verb}Request`; the Options family retires. - -- **Hand-written spine:** `ListOptions.cs` → `ListRequest.cs` (abstract record - `ListRequest`, same Limit/Order/Cursor trio — the M3 paginator seam keeps its shape - under the new name). `ListOrder`, `ListCursor`, `SessionParentFilter` unchanged. -- **Generator:** `OperationNamePolicy.OptionsTypeName` retires; query records derive - through `RequestTypeName`. **Deliberate consequence:** an operation declaring both a - body and query parameters derives the same type name twice and fails the existing - collision wall — that is the intended fail-closed state until a merged-Request design - (one Request carrying both, AWS-marshalling style) is sealed. Record that in the wall's - refusal test. -- **Internal plan/emitter vocabulary** (`OperationOptionsPlan`, `OptionsPropertyPlan`, - `OptionsEmitter`, `OperationPlan.Options`, `QueryValueKind` stays) may rename to - `Query*`/`*Request*` for coherence — internal taste; pick one scheme, be consistent. -- **Signatures:** method and route-builder parameter `options` → `request` - (`ListSessionsAsync(SessionListRequest? request = null, OpenCodeRequestOptions? - requestOptions = null, …)`); `requestOptions` unchanged. Update - `ReservedParameterNames` to the emitted reality (`request`, `requestOptions`, - `cancellationToken`, `pipeline`) — `options` leaves the set. -- **Fallout to update in the same sweep:** regen (SessionListOptions→SessionListRequest, - MessageListOptions→MessageListRequest), all eight emitter snapshots re-verify, the - PublicApi baseline (rename-only diff — review it as such), contract tests, the - `ListMessagesAsync` benchmark, the sandbox program, ROADMAP wording ("`ListOptions` - seam" → `ListRequest`). The research log stays untouched (dated history). - -## Task 2 — Feature-slice layout migration (Q84) - -Target tree (namespaces stay `OpenCode.Sdk` / `OpenCode.Sdk.Models` — folders are -placement, not naming): +| Issues | #20/#25 closed 2026-08-14 with rationale comments; #19/#21/#22 close at merge | +| Review templates | gitignored `.scratchpad/ultra-review-handoff.md` (+ `ultra-review/`) from the M1 cycle — reusable | + +## Work Queue (in this order) + +### A. Alignment batch — on this branch, into PR #26 + +**A1 — Uniform `*Request` rename (Q83).** Every operation input becomes +`{Group}{Subject}{Verb}Request`; the Options family retires. + +- Hand-written spine: `ListOptions.cs` → `ListRequest.cs` (abstract record, same + Limit/Order/Cursor trio — the M3 paginator seam keeps its shape under the new name). + `ListOrder`, `ListCursor`, `SessionParentFilter` unchanged. +- Generator: `OperationNamePolicy.OptionsTypeName` retires; query records derive through + `RequestTypeName`. **Deliberate consequence:** an operation declaring both a body and + query parameters derives the same type name twice and fails the existing collision + wall — the intended fail-closed state until a merged-Request design (one Request + carrying both, AWS-marshalling style) is sealed. Pin that in a refusal test. +- Internal vocabulary (`OperationOptionsPlan`, `OptionsPropertyPlan`, `OptionsEmitter`, + `OperationPlan.Options`) may rename to `Query*`/`*Request*` — internal taste; pick one + scheme, be consistent. +- Signatures: method and route-builder parameter `options` → `request`; + `requestOptions` unchanged. Update `ReservedParameterNames` to emitted reality + (`request`, `requestOptions`, `cancellationToken`, `pipeline`). +- Same-sweep fallout: regen, all emitter snapshots re-verify, PublicApi baseline + (rename-only diff — review as such), contract tests, `ListMessagesAsync` benchmark, + sandbox program, ROADMAP wording ("`ListOptions` seam" → `ListRequest`). The research + log stays untouched (dated history). Perf-neutral — no benchmark re-run. + +**A2 — Feature-slice layout migration (Q84).** Target tree (public namespaces stay +`OpenCode.Sdk` / `OpenCode.Sdk.Models` — folders are placement, not naming): ``` src/OpenCode.Sdk/ @@ -70,41 +73,67 @@ src/OpenCode.Sdk/ Internal/ (unchanged) ``` -- **IDE0130 arbitration:** flat public namespaces over feature folders trip - folder-matches-namespace. Arbitrate per the standing pattern (`.editorconfig` §12 - comment naming the winner), scoped to `src/OpenCode.Sdk` — do not relax the rule for - tools or tests. -- **Emitters:** `GeneratedSource.RelativePath` gains the feature folder. The mapping is - mechanical and name-blind: family files (clients, envelopes, request records) go under - `{RouteContainerName}/`; the root client, `OpenCodeRoutes`, and the registry keep their - current homes. Hand-written files move with `git mv`. -- **Writer allowlist** (`GenerationWriter` source-path check) must learn the family - folders — extend it fail-closed (derive admitted folders from the plan's container - names rather than an open glob). Manifest paths churn once; stale-source deletion - removes the old paths in the same regen. -- **Tests mirror:** reorganize `OpenCode.Sdk.Tests` folders to mirror the new tree - (contract tests under `Sessions/`, spine tests under `Pagination/` etc.); test - namespaces stay flat, same arbitration. `Tools.Tests` already mirrors `tools/`. - -## Task 3 — Extensions bring-up (Q85) - -`src/OpenCode.Sdk.Extensions` is an empty shell today. - -- **Scope:** `AddOpenCode` overloads mirroring the three client constructors - (`(Uri)`, `(Uri, Action?)`, BYO-`HttpClient`), registering the - root `OpenCodeClient` plus sub-client registrations - (`services.AddSingleton(sp => sp.GetRequiredService().Sessions)`) so a - consumer resolves `SessionsClient` directly. Root ownership/dispose stays with the - container's singleton root. -- **Hand-written first:** one client family exists; generator-emitted registrations - become worthwhile when families multiply — leave a research-log note, not code. -- **Dependency policy:** research doc 06 governs what the Extensions package may - reference — read it before adding `Microsoft.Extensions.*` packages, and check whether - `IHttpClientFactory` integration is in or out of the first cut (recommendation: out; - additive later). -- **Tests:** a new `OpenCode.Sdk.Extensions.Tests` project (mirroring layout, TFMs per - the Extensions csproj); cover each overload's composition and the sub-client - resolution path. +- IDE0130 arbitration for the SDK's public folders per the standing `.editorconfig` §12 + pattern — scoped to `src/OpenCode.Sdk`, never relaxed for tools or tests. +- Emitters: `GeneratedSource.RelativePath` gains the feature folder, name-blind + (family files under `{RouteContainerName}/`; root client, `OpenCodeRoutes`, registry + keep their homes). Hand-written files move with `git mv`. +- `GenerationWriter` allowlist learns the family folders fail-closed (derive admitted + folders from the plan's container names, not an open glob). Manifest paths churn once; + stale-source deletion removes old paths in the same regen. +- Tests mirror the new tree (contract tests under `Sessions/`, spine under + `Pagination/`); test namespaces stay flat under the same arbitration. + +**A3 — Extensions bring-up (Q85).** `src/OpenCode.Sdk.Extensions` is an empty shell. + +- Scope: `AddOpenCode` overloads mirroring the three client constructors (`(Uri)`, + `(Uri, Action?)`, BYO-`HttpClient`), registering the root + `OpenCodeClient` plus sub-client registrations + (`sp => sp.GetRequiredService().Sessions`) so a consumer resolves + `SessionsClient` directly; dispose stays with the container's root singleton. +- Hand-written first; generator-emitted registrations become worthwhile when client + families multiply — research-log note, not code. +- Read research doc 06 (dependency policy) before adding any `Microsoft.Extensions.*` + package; `IHttpClientFactory` integration recommended out of the first cut (additive + later). +- New `OpenCode.Sdk.Extensions.Tests` project mirroring layout; cover each overload's + composition and sub-client resolution. + +### B. Finalize PR #26 + +Update the PR body for the alignment additions (rename, layout, Extensions — one short +section; keep the existing content). The PublicApi baseline diff at this point should be +the batch surface plus renames; anything unexplained is a defect. + +### C. External review cycle on the finalized PR + +The maintainer runs multi-agent reviews **outside this harness** from hand-off +instruction files: refresh `.scratchpad/ultra-review-handoff.md` (commit range = the full +PR range, JSON result contract as in the M1 cycle). Findings come back as scratchpad +reports; **triage verifies every finding before trusting it**; blockers are fixed +red-test-first on the branch; every surviving non-blocker is preserved into +milestone-anchored GitHub issues — nothing lives only in scratchpad. + +### D. Merge + +`Closes #19/#21/#22` ride the PR body. After merge, the breadth branch retires. + +### E. M3 pickup + +- **Write the short (1–2 page) M3 plan first** — the ROADMAP convention: a milestone + gets its plan when it starts, never earlier. Align the plan with the maintainer + before executing. +- Scope pointers (ROADMAP M3 + open questions): SSE engine over the v2 stream surface + (`v2.event.subscribe`; `v2.session.log` with `after`/`follow` — resume semantics are + an open question owned by M3); the paginator/`IAsyncEnumerable` convenience over the + `ListRequest` seam, deliberately deferred from M2 to be designed with the stream + surface; #23 (union single-pass deserialization + streaming adapter-boundary + redesign), gated on the #18 performance baselines; the net472 `ServicePointManager` + item. +- **The spec-pin refresh is sanctioned at the M3 boundary** (the M2 plan froze + `a6a712a` deliberately). A refresh may move the `InvalidRequestError1` alias — its + walls are designed to fail loudly on every drift direction; that is expected signal, + not breakage. ## Session Mechanics The Docs Don't Carry @@ -112,21 +141,22 @@ src/OpenCode.Sdk/ explicit ALL-GREEN marker; commit per green increment; push on the working branch is authorized; red-test-first is demanded. - **Decisions to the maintainer go as plain text in chat — never AskUserQuestion - dialogs** (option-dialog annotations have been lost in transit before; the maintainer - rejected the flow). Batch them with a recommendation; they often counter-question, - then seal. -- Regen ritual: edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` → - review the git diff of committed output → snapshot received→verified after review → + dialogs** (option-dialog annotations have been lost in transit; the maintainer + rejected the flow). Batch them with a recommendation; engage their notes in prose + before touching code — jumping to implementation mid-discussion is a standing + complaint. +- Regen ritual: edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` + → review the git diff of committed output → snapshot received→verified after review → `generate --verify` must be a byte-identical no-op before commit. -- The PublicApi baseline diff is maintainer review surface — for this batch it should be - renames and (for Extensions) a new assembly baseline only; anything else is a defect. -- Perf: the rename is perf-neutral; do not re-run benchmarks for it. Extensions adds no - hot path. +- The PublicApi baseline diff is maintainer review surface, never auto-accepted + silently. +- Multi-TFM traps are already paid for — see the agent memory and the test csproj + comments before rediscovering them. ## Stop Conditions The Arc B list carries (binder needing raw OpenAPI, name-branching emitters, structural unions, ownership ambiguity, TFM breakage, partial packs), plus: a selected operation carrying both a request body and query parameters (design the merged Request -deliberately before admitting it), and any Extensions dependency outside research doc -06's policy. +deliberately before admitting it); any Extensions dependency outside research doc 06's +policy; and any M3 stream-surface design that would bypass the plan-first convention. From c26653a579b22d9695d296388373b27a5c9c3a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 13:54:54 +0300 Subject: [PATCH 20/47] feat(sdk): unify operation inputs under the *Request family (Q83) Every operation input is now {Group}{Subject}{Verb}Request and the Options family retires: the hand-written ListOptions seam becomes ListRequest (same Limit/Order/Cursor trio), generated query records derive through RequestTypeName, and the method and route-builder parameter renames options -> request (requestOptions unchanged). Internally the plan vocabulary follows: QueryRequestPlan, QueryPropertyPlan, QueryRequestEmitter, OperationPlan.QueryRequest. ReservedParameterNames shrinks to emitted reality (request, requestOptions, cancellationToken, pipeline); a path parameter named 'options' is admitted again and pinned by test. An operation declaring both a body and query parameters now derives the same type name twice and fails the existing collision wall - the intended fail-closed state until a merged-Request design is sealed, pinned by a refusal test. The PublicApi baseline diff is the rename only, reviewed as such. Perf-neutral: no emitted-shape change beyond names. --- docs/ROADMAP.md | 14 +-- src/OpenCode.Sdk/.generated-manifest.json | 4 +- .../{ListOptions.cs => ListRequest.cs} | 4 +- ...geListOptions.cs => MessageListRequest.cs} | 2 +- src/OpenCode.Sdk/OpenCodeRoutes.cs | 36 +++---- src/OpenCode.Sdk/SessionClient.cs | 6 +- ...onListOptions.cs => SessionListRequest.cs} | 2 +- src/OpenCode.Sdk/SessionsClient.cs | 6 +- tests/OpenCode.Sdk.Sandbox/Program.cs | 4 +- .../SessionClientContractTests.cs | 2 +- .../SessionsClientContractTests.cs | 4 +- .../Snapshots/PublicApi.verified.txt | 30 +++--- .../Binding/OperationNamePolicyTests.cs | 17 ++-- .../Binding/OperationPlanBinderTests.cs | 96 +++++++++++++------ ...erTests.cs => QueryRequestEmitterTests.cs} | 6 +- ...ce_Virtual_Delegating_Clients.verified.txt | 12 +-- ...er_The_Query_Request_Records.verified.txt} | 8 +- ...roduce_Escaped_Route_Builders.verified.txt | 26 ++--- .../Support/EmitterPlanFixture.cs | 32 +++---- .../Generator/Binding/Models/OperationPlan.cs | 4 +- ...nsPropertyPlan.cs => QueryPropertyPlan.cs} | 4 +- ...tionOptionsPlan.cs => QueryRequestPlan.cs} | 10 +- .../Generator/Binding/OperationNamePolicy.cs | 7 -- .../Generator/Binding/OperationPlanBinder.cs | 29 +++--- .../Emission/OperationMethodEmitter.cs | 16 ++-- ...tionsEmitter.cs => QueryRequestEmitter.cs} | 28 +++--- .../Generator/Emission/RoutesEmitter.cs | 24 ++--- .../Generator/Emission/SourceEmitter.cs | 2 +- 28 files changed, 231 insertions(+), 204 deletions(-) rename src/OpenCode.Sdk/{ListOptions.cs => ListRequest.cs} (85%) rename src/OpenCode.Sdk/{MessageListOptions.cs => MessageListRequest.cs} (83%) rename src/OpenCode.Sdk/{SessionListOptions.cs => SessionListRequest.cs} (97%) rename tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/{OptionsEmitterTests.cs => QueryRequestEmitterTests.cs} (52%) rename tests/OpenCode.Sdk.Tools.Tests/Snapshots/{OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt => QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt} (88%) rename tools/OpenCode.Sdk.Tools/Generator/Binding/Models/{OptionsPropertyPlan.cs => QueryPropertyPlan.cs} (77%) rename tools/OpenCode.Sdk.Tools/Generator/Binding/Models/{OperationOptionsPlan.cs => QueryRequestPlan.cs} (60%) rename tools/OpenCode.Sdk.Tools/Generator/Emission/{OptionsEmitter.cs => QueryRequestEmitter.cs} (78%) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bec9239e0..b0373523e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,18 +30,18 @@ M series. **M2's first breadth batch is complete** (plan: `superpowers/plans/2026-08-14-m2-first-breadth-batch.md`; decisions: research log Sessions 19–20). `session.list`, `session.get`, `session.create`, and `message.list` are callable -through their final generated surface — options records over the `ListOptions` seam, -`SessionCreateRequest` bodies through the pipeline's JSON path, cursor-list envelopes with -the shared `ListCursor`, query-composing routes, the `Session.Info` model closure, and the -first 5xx arm. Riders #19 (carrier converters), #21 (fail-closed walls + P2 single-pass +through their final generated surface — uniform `*Request` operation inputs (Q83) with the +query records riding the `ListRequest` seam, `SessionCreateRequest` bodies through the +pipeline's JSON path, cursor-list envelopes with the shared `ListCursor`, query-composing +routes, the `Session.Info` model closure, and the first 5xx arm. Riders #19 (carrier converters), #21 (fail-closed walls + P2 single-pass envelopes: `GetMessageAsync` 67.4→56.2 μs, `ListMessagesAsync` baseline 58.1 μs/28.24 KB), and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `InvalidRequestError1` duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14 against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor round-tripped). The #20 decision landed (blank explicit passwords refuse; `null` keeps the -environment fallback) and #25 closed keep. Next: the alignment batch — uniform `*Request` -naming (Q83), the feature-slice layout migration (Q84), and the Extensions bring-up (Q85) -— handed to the next session (`agents/handover-prompts/HANDOFF-2026-08-14-2.md`); further +environment fallback) and #25 closed keep. The alignment batch is underway: the uniform +`*Request` rename (Q83) has landed; next are the feature-slice layout migration (Q84) and +the Extensions bring-up (Q85) (`agents/handover-prompts/HANDOFF-2026-08-14-2.md`); further breadth batches follow. ## Milestones diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index ad52b3265..a925e1ba4 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -30,7 +30,7 @@ "Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs", "Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs", "Internal/Serialization/UnknownToolContentJsonConverter.cs", - "MessageListOptions.cs", + "MessageListRequest.cs", "MessageListResponse.cs", "Models/FileDiffInfo.cs", "Models/FileDiffInfoStatus.cs", @@ -123,7 +123,7 @@ "OpenCodeRoutes.cs", "SessionClient.cs", "SessionCreateResponse.cs", - "SessionListOptions.cs", + "SessionListRequest.cs", "SessionListResponse.cs", "SessionMessageResponse.cs", "SessionResponse.cs", diff --git a/src/OpenCode.Sdk/ListOptions.cs b/src/OpenCode.Sdk/ListRequest.cs similarity index 85% rename from src/OpenCode.Sdk/ListOptions.cs rename to src/OpenCode.Sdk/ListRequest.cs index e8f834be5..ee9bf4f9c 100644 --- a/src/OpenCode.Sdk/ListOptions.cs +++ b/src/OpenCode.Sdk/ListRequest.cs @@ -2,9 +2,9 @@ namespace OpenCode.Sdk; /// /// The cursor-pagination trio shared by list operations whose wire parameters match it -/// exactly; generated per-operation options records derive from this seam. +/// exactly; generated per-operation request records derive from this seam. /// -public abstract record ListOptions +public abstract record ListRequest { /// Gets the maximum number of entries to return; the server default applies when unset. public int? Limit { get; init; } diff --git a/src/OpenCode.Sdk/MessageListOptions.cs b/src/OpenCode.Sdk/MessageListRequest.cs similarity index 83% rename from src/OpenCode.Sdk/MessageListOptions.cs rename to src/OpenCode.Sdk/MessageListRequest.cs index 299b711a4..7b2a93759 100644 --- a/src/OpenCode.Sdk/MessageListOptions.cs +++ b/src/OpenCode.Sdk/MessageListRequest.cs @@ -4,6 +4,6 @@ namespace OpenCode.Sdk; /// /// Shapes the 'GET /api/session/{sessionID}/message' query. /// -public sealed record MessageListOptions : ListOptions +public sealed record MessageListRequest : ListRequest { } diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index ed96b2777..1c4a4df16 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -83,9 +83,9 @@ public static string GetSession(string sessionId) /// Builds the '/api/session/{sessionID}/message' route. /// /// The 'sessionID' route value. - /// The query options. + /// The request shaping the query. /// The escaped route. - public static string ListMessages(string sessionId, MessageListOptions? options = null) + public static string ListMessages(string sessionId, MessageListRequest? request = null) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); if (sessionId is "." or "..") @@ -94,15 +94,15 @@ public static string ListMessages(string sessionId, MessageListOptions? options } var path = "/api/session/" + Uri.EscapeDataString(sessionId) + "/message"; - if (options is null) + if (request is null) { return path; } var query = new QueryStringBuilder(); - query.AddCount("limit", options.Limit); - query.AddOrder("order", options.Order); - query.AddText("cursor", options.Cursor); + query.AddCount("limit", request.Limit); + query.AddOrder("order", request.Order); + query.AddText("cursor", request.Cursor); return path + query.Value; } @@ -113,26 +113,26 @@ public static string ListMessages(string sessionId, MessageListOptions? options /// /// Builds the '/api/session' route. /// - /// The query options. + /// The request shaping the query. /// The escaped route. - public static string ListSessions(SessionListOptions? options = null) + public static string ListSessions(SessionListRequest? request = null) { var path = "/api/session"; - if (options is null) + if (request is null) { return path; } var query = new QueryStringBuilder(); - query.AddText("workspace", options.Workspace); - query.AddCount("limit", options.Limit); - query.AddOrder("order", options.Order); - query.AddText("search", options.Search); - query.AddParentFilter("parentID", options.ParentId); - query.AddText("directory", options.Directory); - query.AddText("project", options.Project); - query.AddText("subpath", options.Subpath); - query.AddText("cursor", options.Cursor); + query.AddText("workspace", request.Workspace); + query.AddCount("limit", request.Limit); + query.AddOrder("order", request.Order); + query.AddText("search", request.Search); + query.AddParentFilter("parentID", request.ParentId); + query.AddText("directory", request.Directory); + query.AddText("project", request.Project); + query.AddText("subpath", request.Subpath); + query.AddText("cursor", request.Cursor); return path + query.Value; } } diff --git a/src/OpenCode.Sdk/SessionClient.cs b/src/OpenCode.Sdk/SessionClient.cs index 903898f8e..c06b4e737 100644 --- a/src/OpenCode.Sdk/SessionClient.cs +++ b/src/OpenCode.Sdk/SessionClient.cs @@ -60,14 +60,14 @@ public virtual Task GetSessionAsync(OpenCodeRequestOptions? req /// /// Get session messages /// - /// The query options. + /// The request shaping the query. /// The per-call options. /// The cancellation token. /// The 'MessageListResponse' envelope. /// The API returned an error status (declared: 400, 401, 404, 500) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task ListMessagesAsync(MessageListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task ListMessagesAsync(MessageListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListMessages(SessionId, options), MessageListResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListMessages(SessionId, request), MessageListResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/src/OpenCode.Sdk/SessionListOptions.cs b/src/OpenCode.Sdk/SessionListRequest.cs similarity index 97% rename from src/OpenCode.Sdk/SessionListOptions.cs rename to src/OpenCode.Sdk/SessionListRequest.cs index cfc88c93f..1971fb499 100644 --- a/src/OpenCode.Sdk/SessionListOptions.cs +++ b/src/OpenCode.Sdk/SessionListRequest.cs @@ -4,7 +4,7 @@ namespace OpenCode.Sdk; /// /// Shapes the 'GET /api/session' query. /// -public sealed record SessionListOptions +public sealed record SessionListRequest { /// /// Gets the 'workspace' query value; the server default applies when unset. diff --git a/src/OpenCode.Sdk/SessionsClient.cs b/src/OpenCode.Sdk/SessionsClient.cs index 74f962fe3..d2f0cd19b 100644 --- a/src/OpenCode.Sdk/SessionsClient.cs +++ b/src/OpenCode.Sdk/SessionsClient.cs @@ -55,14 +55,14 @@ public virtual Task CreateSessionAsync(SessionCreateReque /// /// List sessions /// - /// The query options. + /// The request shaping the query. /// The per-call options. /// The cancellation token. /// The 'SessionListResponse' 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 ListSessionsAsync(SessionListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task ListSessionsAsync(SessionListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListSessions(options), SessionListResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListSessions(request), SessionListResponseAdapter.Instance, requestOptions, cancellationToken); } } diff --git a/tests/OpenCode.Sdk.Sandbox/Program.cs b/tests/OpenCode.Sdk.Sandbox/Program.cs index 8bcf4569c..bfbcd52be 100644 --- a/tests/OpenCode.Sdk.Sandbox/Program.cs +++ b/tests/OpenCode.Sdk.Sandbox/Program.cs @@ -28,7 +28,7 @@ await Console.Error.WriteLineAsync( CultureInfo.InvariantCulture, $"create: status={created.Status} id={created.Session.Id} title={created.Session.Title}")); -var page = await client.Sessions.ListSessionsAsync(new SessionListOptions +var page = await client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 3, Order = ListOrder.Descending, @@ -47,7 +47,7 @@ await Console.Error.WriteLineAsync( CultureInfo.InvariantCulture, $"get: status={fetched.Status} id={fetched.Session.Id} directory={fetched.Session.Location.Directory}")); -var messages = await handle.ListMessagesAsync(new MessageListOptions +var messages = await handle.ListMessagesAsync(new MessageListRequest { Limit = 5, }).ConfigureAwait(false); diff --git a/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs index 5b71ccba0..cdc08e5d5 100644 --- a/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs @@ -69,7 +69,7 @@ public async Task ListMessagesAsync_Should_Return_The_Typed_Page_With_Its_Cursor using var httpClient = new HttpClient(handler); using var client = CreateClient(httpClient); - var response = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListOptions + var response = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync(new MessageListRequest { Limit = 2, Order = ListOrder.Ascending, diff --git a/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs index fe5d9490b..26507b5b7 100644 --- a/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs @@ -37,7 +37,7 @@ public async Task ListSessionsAsync_Should_Compose_The_Escaped_Query() using var httpClient = new HttpClient(handler); using var client = CreateClient(httpClient); - _ = await client.Sessions.ListSessionsAsync(new SessionListOptions + _ = await client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 5, Order = ListOrder.Descending, @@ -58,7 +58,7 @@ public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Se using var client = CreateClient(httpClient); _ = await Assert - .That(async () => _ = await client.Sessions.ListSessionsAsync(new SessionListOptions { Limit = 0 })) + .That(async () => _ = await client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 0 })) .Throws(); await Assert.That(handler.Requests).IsEmpty(); } diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index e6a425703..aab318458 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -19,21 +19,21 @@ [System.Text.Json.Serialization.JsonPropertyName("previous")] public string? Previous { get; init; } } - public abstract class ListOptions : System.IEquatable - { - protected ListOptions() { } - public string? Cursor { get; init; } - public int? Limit { get; init; } - public OpenCode.Sdk.ListOrder? Order { get; init; } - } public enum ListOrder { Ascending = 0, Descending = 1, } - public sealed class MessageListOptions : OpenCode.Sdk.ListOptions, System.IEquatable + public abstract class ListRequest : System.IEquatable + { + protected ListRequest() { } + public string? Cursor { get; init; } + public int? Limit { get; init; } + public OpenCode.Sdk.ListOrder? Order { get; init; } + } + public sealed class MessageListRequest : OpenCode.Sdk.ListRequest, System.IEquatable { - public MessageListOptions() { } + public MessageListRequest() { } } public sealed class MessageListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { @@ -104,8 +104,8 @@ public const string ListSessionsTemplate = "/api/session"; public static string GetMessage(string sessionId, string messageId) { } public static string GetSession(string sessionId) { } - public static string ListMessages(string sessionId, OpenCode.Sdk.MessageListOptions? options = null) { } - public static string ListSessions(OpenCode.Sdk.SessionListOptions? options = null) { } + public static string ListMessages(string sessionId, OpenCode.Sdk.MessageListRequest? request = null) { } + public static string ListSessions(OpenCode.Sdk.SessionListRequest? request = null) { } } } public class OpenCodeTransportException : OpenCode.Sdk.OpenCodeException @@ -119,7 +119,7 @@ protected SessionClient() { } 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.MessageListOptions? options = null, 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 sealed class SessionCreateResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { @@ -127,9 +127,9 @@ public required OpenCode.Sdk.Models.SessionInfo Session { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } - public sealed class SessionListOptions : System.IEquatable + public sealed class SessionListRequest : System.IEquatable { - public SessionListOptions() { } + public SessionListRequest() { } public string? Cursor { get; init; } public string? Directory { get; init; } public int? Limit { get; init; } @@ -169,7 +169,7 @@ protected SessionsClient() { } public virtual System.Threading.Tasks.Task CreateSessionAsync(OpenCode.Sdk.Models.SessionCreateRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual OpenCode.Sdk.SessionClient GetSessionClient(string sessionId) { } - public virtual System.Threading.Tasks.Task ListSessionsAsync(OpenCode.Sdk.SessionListOptions? options = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual System.Threading.Tasks.Task ListSessionsAsync(OpenCode.Sdk.SessionListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } } namespace OpenCode.Sdk.Models diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs index 5573cd5c9..84c1d20b1 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationNamePolicyTests.cs @@ -78,18 +78,13 @@ public async Task PayloadName_Should_Return_Null_When_The_Group_Cannot_Be_Plural } [Test] - [Arguments("v2.session.list", "SessionListOptions")] - [Arguments("v2.message.list", "MessageListOptions")] - public async Task OptionsTypeName_Should_Compose_Group_Subject_And_Verb(string operationId, string expected) - { - await Assert.That(OperationNamePolicy.OptionsTypeName(Operation(operationId))).IsEqualTo(expected); - } - - [Test] - public async Task RequestTypeName_Should_Compose_Group_Subject_And_Verb() + [Arguments("v2.session.list", "get", "SessionListRequest")] + [Arguments("v2.message.list", "get", "MessageListRequest")] + [Arguments("v2.session.create", "post", "SessionCreateRequest")] + public async Task RequestTypeName_Should_Compose_Group_Subject_And_Verb(string operationId, string method, + string expected) { - await Assert.That(OperationNamePolicy.RequestTypeName(Operation("v2.session.create", "post"))) - .IsEqualTo("SessionCreateRequest"); + await Assert.That(OperationNamePolicy.RequestTypeName(Operation(operationId, method))).IsEqualTo(expected); } private static SpecOperation Operation(string operationId, string method = "get") diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 66e32a07f..1e3f5c4a2 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -54,9 +54,9 @@ await Assert.That(sessions.Operations.Select(static operation => operation.Metho await Assert.That(sessions.HandleFactory.Parameter.TypeName).IsEqualTo("string"); var list = sessions.Operations.Single(static operation => operation.MethodName == "ListSessionsAsync"); - await Assert.That(list.Options!.TypeName).IsEqualTo("SessionListOptions"); - await Assert.That(list.Options.DerivesFromListOptions).IsFalse(); - await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("SessionListRequest"); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) .SequenceEqual( ["Workspace", "Limit", "Order", "Search", "ParentId", "Directory", "Project", "Subpath", "Cursor"], StringComparer.Ordinal)).IsTrue(); @@ -87,8 +87,8 @@ await Assert.That(session.Operations.Select(static operation => operation.Method .SequenceEqual(["GetMessageAsync", "GetSessionAsync", "ListMessagesAsync"], StringComparer.Ordinal)).IsTrue(); var messages = session.Operations.Single(static operation => operation.MethodName == "ListMessagesAsync"); - await Assert.That(messages.Options!.TypeName).IsEqualTo("MessageListOptions"); - await Assert.That(messages.Options.DerivesFromListOptions).IsTrue(); + await Assert.That(messages.QueryRequest!.TypeName).IsEqualTo("MessageListRequest"); + await Assert.That(messages.QueryRequest.DerivesFromListRequest).IsTrue(); await Assert.That(messages.Envelope.Kind).IsEqualTo(EnvelopeKind.CursorList); await Assert.That(messages.Envelope.PayloadName).IsEqualTo("Messages"); await Assert.That(messages.ErrorMap.Statuses.Select(static status => status.StatusCode) @@ -299,7 +299,7 @@ public async Task Bind_Should_Refuse_An_Optional_Request_Body() } [Test] - public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Into_Flat_Options() + public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Into_A_Flat_Query_Request() { var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation .Parameter("limit", "query", QueryScenarioData.NullableString) @@ -311,14 +311,14 @@ public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Into_Flat_ var plan = BindWidgets(document); var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); - await Assert.That(list.Options).IsNotNull(); - await Assert.That(list.Options!.TypeName).IsEqualTo("WidgetListOptions"); - await Assert.That(list.Options.DerivesFromListOptions).IsFalse(); - await Assert.That(list.Options.Properties.Select(static property => property.WireName) + await Assert.That(list.QueryRequest).IsNotNull(); + await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("WidgetListRequest"); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.WireName) .SequenceEqual(["limit", "order", "cursor", "search", "parentID"], StringComparer.Ordinal)).IsTrue(); - await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) .SequenceEqual(["Limit", "Order", "Cursor", "Search", "ParentId"], StringComparer.Ordinal)).IsTrue(); - await Assert.That(list.Options.Properties.Select(static property => property.Kind) + await Assert.That(list.QueryRequest.Properties.Select(static property => property.Kind) .SequenceEqual([ QueryValueKind.PositiveCount, QueryValueKind.ListOrder, @@ -326,7 +326,7 @@ await Assert.That(list.Options.Properties.Select(static property => property.Kin QueryValueKind.Text, QueryValueKind.SessionParentFilter, ])).IsTrue(); - await Assert.That(list.Options.Properties.All(static property => !property.IsInherited)).IsTrue(); + await Assert.That(list.QueryRequest.Properties.All(static property => !property.IsInherited)).IsTrue(); await Assert.That(list.Parameters).IsEmpty(); } @@ -347,7 +347,7 @@ await Assert.That(plan.Registry.TypeNames } [Test] - public async Task Bind_Should_Derive_Options_From_The_List_Options_Base_When_The_Trio_Matches_Exactly() + public async Task Bind_Should_Derive_The_Query_Request_From_The_List_Request_Base_When_The_Trio_Matches_Exactly() { var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation .Parameter("limit", "query", QueryScenarioData.NullableString) @@ -357,15 +357,15 @@ public async Task Bind_Should_Derive_Options_From_The_List_Options_Base_When_The var plan = BindWidgets(document); var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); - await Assert.That(list.Options!.TypeName).IsEqualTo("WidgetListOptions"); - await Assert.That(list.Options.DerivesFromListOptions).IsTrue(); - await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("WidgetListRequest"); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsTrue(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) .SequenceEqual(["Limit", "Order", "Cursor"], StringComparer.Ordinal)).IsTrue(); - await Assert.That(list.Options.Properties.All(static property => property.IsInherited)).IsTrue(); + await Assert.That(list.QueryRequest.Properties.All(static property => property.IsInherited)).IsTrue(); } [Test] - public async Task Bind_Should_Keep_Options_Flat_When_The_Trio_Has_Extra_Parameters() + public async Task Bind_Should_Keep_The_Query_Request_Flat_When_The_Trio_Has_Extra_Parameters() { var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation .Parameter("limit", "query", QueryScenarioData.NullableString) @@ -376,12 +376,12 @@ public async Task Bind_Should_Keep_Options_Flat_When_The_Trio_Has_Extra_Paramete var plan = BindWidgets(document); var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); - await Assert.That(list.Options!.DerivesFromListOptions).IsFalse(); - await Assert.That(list.Options.Properties.All(static property => !property.IsInherited)).IsTrue(); + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.All(static property => !property.IsInherited)).IsTrue(); } [Test] - public async Task Bind_Should_Keep_Options_Flat_When_The_Trio_Is_Incomplete() + public async Task Bind_Should_Keep_The_Query_Request_Flat_When_The_Trio_Is_Incomplete() { var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation .Parameter("limit", "query", QueryScenarioData.NullableString) @@ -390,13 +390,13 @@ public async Task Bind_Should_Keep_Options_Flat_When_The_Trio_Is_Incomplete() var plan = BindWidgets(document); var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); - await Assert.That(list.Options!.DerivesFromListOptions).IsFalse(); - await Assert.That(list.Options.Properties.Select(static property => property.PropertyName) + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.Select(static property => property.PropertyName) .SequenceEqual(["Limit", "Cursor"], StringComparer.Ordinal)).IsTrue(); } [Test] - public async Task Bind_Should_Leave_Options_Absent_When_An_Operation_Has_No_Query_Parameters() + public async Task Bind_Should_Leave_The_Query_Request_Absent_When_An_Operation_Has_No_Query_Parameters() { var document = await BindingTestHost.IngestAsync(GadgetScenario()); @@ -406,7 +406,7 @@ public async Task Bind_Should_Leave_Options_Absent_When_An_Operation_Has_No_Quer Curation(Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: "GadgetClient", handleParameter: "gadgetID")))); var part = plan.Clients.Single(static client => client.Role == ClientRole.Handle).Operations.Single(); - await Assert.That(part.Options).IsNull(); + await Assert.That(part.QueryRequest).IsNull(); } [Test] @@ -719,8 +719,8 @@ public async Task Bind_Should_Refuse_A_Reserved_Parameter_Name() var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec .WithSchema("ItemInfo", schema => schema.Type("object") .Property("id", property => property.Type("string"), required: true)) - .WithOperation("v2.widget.item", path: "/api/widget/{options}", configure: operation => operation - .Parameter("options", "path", schema => schema.Type("string"), required: true) + .WithOperation("v2.widget.item", path: "/api/widget/{request}", configure: operation => operation + .Parameter("request", "path", schema => schema.Type("string"), required: true) .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( @@ -732,6 +732,46 @@ await Assert.That(exception.Errors.Any(static error => error.Category == Binding && error.Problem.Contains("reserved by the emitted method signature", StringComparison.Ordinal))).IsTrue(); } + [Test] + public async Task Bind_Should_Admit_A_Path_Parameter_Named_Options() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.item", path: "/api/widget/{options}", configure: operation => operation + .Parameter("options", "path", schema => schema.Type("string"), required: true) + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)))); + + var item = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(item.Parameters.Single().Name).IsEqualTo("options"); + } + + [Test] + public async Task Bind_Should_Refuse_An_Operation_Declaring_Both_A_Body_And_Query_Parameters() + { + 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("dryRun", "query", QueryScenarioData.NullableString) + .RequestBody("application/json", body => body.Type("object") + .Property("title", property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + var exception = Assert.Throws(() => _ = BindWidgets(document, "v2.widget.create")); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("WidgetCreateRequest", StringComparison.Ordinal) + && error.Problem.Contains("collides", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Refuse_A_Response_Type_Shadowing_A_Model() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/OptionsEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/QueryRequestEmitterTests.cs similarity index 52% rename from tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/OptionsEmitterTests.cs rename to tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/QueryRequestEmitterTests.cs index 563ace8fa..f969a26f5 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/OptionsEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/QueryRequestEmitterTests.cs @@ -3,12 +3,12 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Emission; -public sealed class OptionsEmitterTests +public sealed class QueryRequestEmitterTests { [Test] - public async Task Emit_Should_Render_The_Options_Records() + public async Task Emit_Should_Render_The_Query_Request_Records() { - var sources = OptionsEmitter.Emit(EmitterPlanFixture.CreateClientPlans()); + var sources = QueryRequestEmitter.Emit(EmitterPlanFixture.CreateClientPlans()); await Verify(EmitterSnapshot.Create(sources)); } 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 da09c2b37..1f830314f 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 @@ -152,15 +152,15 @@ public class WidgetClient /// /// List the widget items /// - /// The query options. + /// The request shaping the query. /// The per-call options. /// The cancellation token. /// The 'WidgetItemListResponse' envelope. /// The API returned an error status (declared: 404) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task ListItemsAsync(ItemListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task ListItemsAsync(ItemListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListItems(WidgetId, options), WidgetItemListResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListItems(WidgetId, request), WidgetItemListResponseAdapter.Instance, requestOptions, cancellationToken); } } @@ -225,15 +225,15 @@ public class WidgetsClient /// /// List the widgets /// - /// The query options. + /// The request shaping the query. /// The per-call options. /// The cancellation token. /// The 'WidgetListResponse' envelope. /// The API returned an error status (declared: 400, 500) and NoThrow was not selected. /// The server could not be reached or returned a malformed success body. - public virtual Task ListWidgetsAsync(WidgetListOptions? options = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + public virtual Task ListWidgetsAsync(WidgetListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { - return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListWidgets(options), WidgetListResponseAdapter.Instance, requestOptions, cancellationToken); + return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Widgets.ListWidgets(request), WidgetListResponseAdapter.Instance, requestOptions, cancellationToken); } /// diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt similarity index 88% rename from tests/OpenCode.Sdk.Tools.Tests/Snapshots/OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt rename to tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt index d151c8907..dd752f6bd 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/OptionsEmitterTests.Emit_Should_Render_The_Options_Records.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/QueryRequestEmitterTests.Emit_Should_Render_The_Query_Request_Records.verified.txt @@ -1,22 +1,22 @@ -// File: ItemListOptions.cs +// File: ItemListRequest.cs // 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/widget/{widgetID}/item' query. /// -public sealed record ItemListOptions : ListOptions +public sealed record ItemListRequest : ListRequest { } -// File: WidgetListOptions.cs +// File: WidgetListRequest.cs // 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/widget' query. /// -public sealed record WidgetListOptions +public sealed record WidgetListRequest { /// /// Gets the 'limit' query value; the server default applies when unset. 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 c1254cf2a..4dcb692c1 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 @@ -69,9 +69,9 @@ public static class OpenCodeRoutes /// Builds the '/api/widget/{widgetID}/item' route. /// /// The 'widgetID' route value. - /// The query options. + /// The request shaping the query. /// The escaped route. - public static string ListItems(string widgetId, ItemListOptions? options = null) + public static string ListItems(string widgetId, ItemListRequest? request = null) { ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); if (widgetId is "." or "..") @@ -80,15 +80,15 @@ public static class OpenCodeRoutes } var path = "/api/widget/" + Uri.EscapeDataString(widgetId) + "/item"; - if (options is null) + if (request is null) { return path; } var query = new QueryStringBuilder(); - query.AddCount("limit", options.Limit); - query.AddOrder("order", options.Order); - query.AddText("cursor", options.Cursor); + query.AddCount("limit", request.Limit); + query.AddOrder("order", request.Order); + query.AddText("cursor", request.Cursor); return path + query.Value; } @@ -99,21 +99,21 @@ public static class OpenCodeRoutes /// /// Builds the '/api/widget' route. /// - /// The query options. + /// The request shaping the query. /// The escaped route. - public static string ListWidgets(WidgetListOptions? options = null) + public static string ListWidgets(WidgetListRequest? request = null) { var path = "/api/widget"; - if (options is null) + if (request is null) { return path; } var query = new QueryStringBuilder(); - query.AddCount("limit", options.Limit); - query.AddOrder("order", options.Order); - query.AddText("cursor", options.Cursor); - query.AddParentFilter("parentID", options.ParentId); + query.AddCount("limit", request.Limit); + query.AddOrder("order", request.Order); + query.AddText("cursor", request.Cursor); + query.AddParentFilter("parentID", request.ParentId); 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 4a1c289c7..884de4e1a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -212,7 +212,7 @@ private static OperationParameterPlan CreateWidgetParameter() => IsHandleParameter = true, }; - /// A flat options list operation covering every query value kind, a cursor envelope, and a 5xx arm. + /// A flat query-request list operation covering every query value kind, a cursor envelope, and a 5xx arm. private static OperationPlan CreateWidgetListOperation() => new() { @@ -222,16 +222,16 @@ private static OperationPlan CreateWidgetListOperation() => RouteContainerName = "Widgets", RouteMemberName = "ListWidgets", Parameters = [], - Options = new OperationOptionsPlan + QueryRequest = new QueryRequestPlan { - TypeName = "WidgetListOptions", - DerivesFromListOptions = false, + TypeName = "WidgetListRequest", + DerivesFromListRequest = false, Properties = [ - OptionsProperty("limit", "Limit", QueryValueKind.PositiveCount), - OptionsProperty("order", "Order", QueryValueKind.ListOrder), - OptionsProperty("cursor", "Cursor", QueryValueKind.Text), - OptionsProperty("parentID", "ParentId", QueryValueKind.SessionParentFilter), + QueryProperty("limit", "Limit", QueryValueKind.PositiveCount), + QueryProperty("order", "Order", QueryValueKind.ListOrder), + QueryProperty("cursor", "Cursor", QueryValueKind.Text), + QueryProperty("parentID", "ParentId", QueryValueKind.SessionParentFilter), ], }, Envelope = new EnvelopePlan @@ -263,7 +263,7 @@ private static OperationPlan CreateWidgetListOperation() => Description = null, }; - /// A handle-scoped list whose options derive from the ListOptions base. + /// A handle-scoped list whose query request derives from the ListRequest base. private static OperationPlan CreateItemListOperation() => new() { @@ -273,15 +273,15 @@ private static OperationPlan CreateItemListOperation() => RouteContainerName = "Widgets", RouteMemberName = "ListItems", Parameters = [CreateWidgetParameter()], - Options = new OperationOptionsPlan + QueryRequest = new QueryRequestPlan { - TypeName = "ItemListOptions", - DerivesFromListOptions = true, + TypeName = "ItemListRequest", + DerivesFromListRequest = true, Properties = [ - OptionsProperty("limit", "Limit", QueryValueKind.PositiveCount, isInherited: true), - OptionsProperty("order", "Order", QueryValueKind.ListOrder, isInherited: true), - OptionsProperty("cursor", "Cursor", QueryValueKind.Text, isInherited: true), + QueryProperty("limit", "Limit", QueryValueKind.PositiveCount, isInherited: true), + QueryProperty("order", "Order", QueryValueKind.ListOrder, isInherited: true), + QueryProperty("cursor", "Cursor", QueryValueKind.Text, isInherited: true), ], }, Envelope = new EnvelopePlan @@ -348,7 +348,7 @@ private static OperationPlan CreateWidgetCreateOperation() => Description = null, }; - private static OptionsPropertyPlan OptionsProperty(string wireName, string propertyName, QueryValueKind kind, + private static QueryPropertyPlan QueryProperty(string wireName, string propertyName, QueryValueKind kind, bool isInherited = false) => new() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs index 16b3f60f6..3006f30b2 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationPlan.cs @@ -26,8 +26,8 @@ public required IReadOnlyList Parameters } } = Array.AsReadOnly(Array.Empty()); - /// Gets the query-options record plan, or when the operation has no query parameters. - public OperationOptionsPlan? Options { get; init; } + /// Gets the query-request record plan, or when the operation has no query parameters. + public QueryRequestPlan? QueryRequest { get; init; } /// Gets the request-body plan, or when the operation sends no body. public RequestBodyPlan? RequestBody { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OptionsPropertyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryPropertyPlan.cs similarity index 77% rename from tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OptionsPropertyPlan.cs rename to tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryPropertyPlan.cs index 32da7c7b4..4341afcc8 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OptionsPropertyPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryPropertyPlan.cs @@ -1,6 +1,6 @@ namespace OpenCode.Sdk.Tools.Generator.Binding.Models; -internal sealed record OptionsPropertyPlan +internal sealed record QueryPropertyPlan { public required string WireName { get; init; } @@ -8,6 +8,6 @@ internal sealed record OptionsPropertyPlan public required QueryValueKind Kind { get; init; } - /// Gets a value indicating whether the property is inherited from the ListOptions base. + /// Gets a value indicating whether the property is inherited from the ListRequest base. public required bool IsInherited { get; init; } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationOptionsPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs similarity index 60% rename from tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationOptionsPlan.cs rename to tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs index 8b79c0440..606637d21 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationOptionsPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/QueryRequestPlan.cs @@ -1,14 +1,14 @@ namespace OpenCode.Sdk.Tools.Generator.Binding.Models; -internal sealed record OperationOptionsPlan +internal sealed record QueryRequestPlan { public required string TypeName { get; init; } - /// Gets a value indicating whether the record derives from the ListOptions base. - public required bool DerivesFromListOptions { get; init; } + /// Gets a value indicating whether the record derives from the ListRequest base. + public required bool DerivesFromListRequest { get; init; } /// Gets every bound query parameter in wire order, including base-inherited ones. - public required IReadOnlyList Properties + public required IReadOnlyList Properties { get; init @@ -16,5 +16,5 @@ public required IReadOnlyList Properties ArgumentNullException.ThrowIfNull(value); field = Array.AsReadOnly([.. value]); } - } = Array.AsReadOnly(Array.Empty()); + } = Array.AsReadOnly(Array.Empty()); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index c7bf5d482..07107fdc3 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -67,13 +67,6 @@ public static string ResponseTypeName(SpecOperation operation) return subject.Length is 0 ? GroupFallback(operation) : subject; } - public static string OptionsTypeName(SpecOperation operation) - { - ArgumentNullException.ThrowIfNull(operation); - - return $"{CSharpNamePolicy.ToPascalCase(operation.Segments[0])}{Subject(operation)}{Verb(operation)}Options"; - } - public static string RequestTypeName(SpecOperation operation) { ArgumentNullException.ThrowIfNull(operation); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 4b2bfa584..7487f31f8 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -20,8 +20,8 @@ internal sealed class OperationPlanBinder [ "ErrorBehavior", "ListCursor", - "ListOptions", "ListOrder", + "ListRequest", "OpenCodeApiException", "OpenCodeClientOptions", "OpenCodeException", @@ -35,7 +35,6 @@ internal sealed class OperationPlanBinder private static readonly string[] ReservedParameterNames = [ "cancellationToken", - "options", "pipeline", "request", "requestOptions", @@ -224,12 +223,12 @@ private void CheckTypeNameCollisions(List clients, List - operation.Plan.Options is not null && !owners.Add(operation.Plan.Options.TypeName))) + operation.Plan.QueryRequest is not null && !owners.Add(operation.Plan.QueryRequest.TypeName))) { errors.Add( BindingErrorCategory.Naming, operation.OperationId, - $"options type name '{operation.Plan.Options!.TypeName}' collides with another generated type"); + $"request type name '{operation.Plan.QueryRequest!.TypeName}' collides with another generated type"); } var routeMembers = new HashSet(_comparer); @@ -285,7 +284,7 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation var errorMap = BindErrorMap(); var parameters = BindParameters(row); var optionalPlanErrorsBefore = _errors.Count; - var options = BindQueryOptions(); + var queryRequest = BindQueryRequest(); var requestBody = BindRequestBody(); var methodName = OperationNamePolicy.MethodName(_operation); var routeMemberName = OperationNamePolicy.RouteMemberName(_operation, row.Placement); @@ -314,7 +313,7 @@ private sealed class SingleOperationBinder(SpecDocument document, SpecOperation RouteContainerName = row.ClientName ?? CSharpNamePolicy.ToPascalCase(group), RouteMemberName = routeMemberName, Parameters = parameters, - Options = options, + QueryRequest = queryRequest, RequestBody = requestBody, Envelope = envelope, ErrorMap = errorMap, @@ -695,7 +694,7 @@ private int TemplatePosition(string wireName) return position; } - private OperationOptionsPlan? BindQueryOptions() + private QueryRequestPlan? BindQueryRequest() { var query = _operation.Parameters .Where(static parameter => parameter.Location is SpecParameterLocation.Query) @@ -705,7 +704,7 @@ private int TemplatePosition(string wireName) return null; } - var properties = new List(query.Length); + var properties = new List(query.Length); foreach (var parameter in query) { if (parameter.IsDeepObject) @@ -733,7 +732,7 @@ private int TemplatePosition(string wireName) continue; } - properties.Add(new OptionsPropertyPlan + properties.Add(new QueryPropertyPlan { WireName = parameter.Name, PropertyName = CSharpNamePolicy.ToPascalCase(parameter.Name), @@ -753,15 +752,15 @@ private int TemplatePosition(string wireName) return null; } - if (MatchesListOptionsProfile(properties)) + if (MatchesListRequestProfile(properties)) { properties = [.. properties.Select(static property => property with { IsInherited = true })]; } - return new OperationOptionsPlan + return new QueryRequestPlan { - TypeName = OperationNamePolicy.OptionsTypeName(_operation), - DerivesFromListOptions = properties.Count > 0 && properties[0].IsInherited, + TypeName = OperationNamePolicy.RequestTypeName(_operation), + DerivesFromListRequest = properties.Count > 0 && properties[0].IsInherited, Properties = properties, }; } @@ -807,10 +806,10 @@ private int TemplatePosition(string wireName) } /// - /// The fail-closed profile wall: an operation derives from the ListOptions base + /// The fail-closed profile wall: an operation derives from the ListRequest base /// only when its wire query parameters are exactly the cursor-pagination trio. /// - private static bool MatchesListOptionsProfile(List properties) => + private static bool MatchesListRequestProfile(List properties) => properties.Count is 3 && properties.Any(static property => property is { WireName: "limit", Kind: QueryValueKind.PositiveCount }) && properties.Any(static property => property is { WireName: "order", Kind: QueryValueKind.ListOrder }) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs index 15df2b0e6..4f7526dbd 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/OperationMethodEmitter.cs @@ -58,10 +58,10 @@ private static IEnumerable EmitParameters(OperationPlan operati : body.WithType(TypeSyntaxEmitter.EmitNamed(operation.RequestBody.TypeName)); } - if (operation.Options is not null) + if (operation.QueryRequest is not null) { - yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")) - .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.Options.TypeName))) + yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("request")) + .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.QueryRequest.TypeName))) .WithDefault(SyntaxFactory.EqualsValueClause(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))); } @@ -124,7 +124,7 @@ private static ExpressionSyntax EmitRoute(OperationPlan operation) SyntaxFactory.IdentifierName("OpenCodeRoutes"), operation.RouteContainerName), operation.RouteMemberName); - if (operation.Parameters.Count is 0 && operation.Options is null) + if (operation.Parameters.Count is 0 && operation.QueryRequest is null) { return member; } @@ -133,9 +133,9 @@ private static ExpressionSyntax EmitRoute(OperationPlan operation) arguments.AddRange(operation.Parameters.Select(static parameter => SyntaxFactory.Argument(parameter.IsHandleParameter ? SyntaxFactory.IdentifierName(CSharpNamePolicy.ToPascalCase(parameter.Name)) : SyntaxFactory.IdentifierName(parameter.Name)))); - if (operation.Options is not null) + if (operation.QueryRequest is not null) { - arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options"))); + arguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("request"))); } return EmissionSyntax.Invocation(member, [.. arguments]); @@ -155,9 +155,9 @@ private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, IRead : "The request body.")); } - if (operation.Options is not null) + if (operation.QueryRequest is not null) { - parameters.Add(new DocumentedParameter("options", "The query options.")); + parameters.Add(new DocumentedParameter("request", "The request shaping the query.")); } parameters.Add(new DocumentedParameter("requestOptions", "The per-call options.")); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/OptionsEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs similarity index 78% rename from tools/OpenCode.Sdk.Tools/Generator/Emission/OptionsEmitter.cs rename to tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs index 62d14c53c..548918bba 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/OptionsEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs @@ -6,10 +6,10 @@ namespace OpenCode.Sdk.Tools.Generator.Emission; /// -/// Emits one public options record per query-carrying operation: derived records inherit the -/// ListOptions trio verbatim, flat records declare every bound query property. +/// Emits one public request record per query-carrying operation: derived records inherit the +/// ListRequest trio verbatim, flat records declare every bound query property. /// -internal static class OptionsEmitter +internal static class QueryRequestEmitter { public static IReadOnlyList Emit(IReadOnlyList clients) { @@ -19,16 +19,16 @@ public static IReadOnlyList Emit(IReadOnlyList clie [ .. clients .SelectMany(static client => client.Operations) - .Where(static operation => operation.Options is not null) - .OrderBy(static operation => operation.Options!.TypeName, StringComparer.Ordinal) - .Select(static operation => EmitOptions(operation)), + .Where(static operation => operation.QueryRequest is not null) + .OrderBy(static operation => operation.QueryRequest!.TypeName, StringComparer.Ordinal) + .Select(static operation => EmitQueryRequest(operation)), ]); } - private static GeneratedSource EmitOptions(OperationPlan operation) + private static GeneratedSource EmitQueryRequest(OperationPlan operation) { - var options = operation.Options!; - var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), options.TypeName) + var queryRequest = operation.QueryRequest!; + var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), queryRequest.TypeName) .WithModifiers(SyntaxFactory.TokenList( SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.SealedKeyword))) @@ -36,23 +36,23 @@ private static GeneratedSource EmitOptions(OperationPlan operation) .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken)) .WithMembers(SyntaxFactory.List( [ - .. options.Properties + .. queryRequest.Properties .Where(static property => !property.IsInherited) .Select(static property => EmitProperty(property)), ])) .WithLeadingTrivia(EmissionSyntax.Documentation( $"Shapes the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' query.")); - if (options.DerivesFromListOptions) + if (queryRequest.DerivesFromListRequest) { declaration = declaration.WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("ListOptions"))))); + SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed("ListRequest"))))); } var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", [], [declaration]); - return EmissionSyntax.CreateSource($"{options.TypeName}.cs", unit); + return EmissionSyntax.CreateSource($"{queryRequest.TypeName}.cs", unit); } - private static PropertyDeclarationSyntax EmitProperty(OptionsPropertyPlan property) + private 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 04de9aa74..2313f19d0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -29,7 +29,7 @@ public static GeneratedSource Emit(IReadOnlyList clients) usings.Add("System"); } - if (operations.Any(static operation => operation.Options is not null)) + if (operations.Any(static operation => operation.QueryRequest is not null)) { usings.Add("OpenCode.Sdk.Internal"); } @@ -43,7 +43,7 @@ private static ClassDeclarationSyntax EmitContainer(IGrouping(); foreach (var operation in container.OrderBy(static operation => operation.RouteMemberName, StringComparer.Ordinal)) { - if (operation.Parameters.Count is 0 && operation.Options is null) + if (operation.Parameters.Count is 0 && operation.QueryRequest is null) { members.Add(EmitConst( operation.RouteMemberName, @@ -87,7 +87,7 @@ private static MethodDeclarationSyntax EmitBuilder(OperationPlan operation) statements.AddRange(EmitRouteValueGuard(parameter.Name)); } - if (operation.Options is null) + if (operation.QueryRequest is null) { statements.Add(SyntaxFactory.ReturnStatement(EmitConcatenation(operation))); } @@ -99,9 +99,9 @@ private static MethodDeclarationSyntax EmitBuilder(OperationPlan operation) var parameters = new List(); parameters.AddRange(operation.Parameters.Select(static parameter => new DocumentedParameter(parameter.Name, $"The '{parameter.WireName}' route value."))); - if (operation.Options is not null) + if (operation.QueryRequest is not null) { - parameters.Add(new DocumentedParameter("options", "The query options.")); + parameters.Add(new DocumentedParameter("request", "The request shaping the query.")); } var documentation = EmissionSyntax.MemberDocumentation( @@ -127,15 +127,15 @@ private static IEnumerable EmitBuilderParameters(OperationPlan .WithType(TypeSyntaxEmitter.EmitNamed(parameter.TypeName)); } - if (operation.Options is not null) + if (operation.QueryRequest is not null) { - yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")) - .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.Options.TypeName))) + yield return SyntaxFactory.Parameter(SyntaxFactory.Identifier("request")) + .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed(operation.QueryRequest.TypeName))) .WithDefault(SyntaxFactory.EqualsValueClause(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))); } } - /// Unset options short-circuit to the bare path; set ones append the composed query suffix. + /// An unset request short-circuits to the bare path; a set one appends the composed query suffix. private static IEnumerable EmitQueryComposition(OperationPlan operation) { yield return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration( @@ -144,7 +144,7 @@ private static IEnumerable EmitQueryComposition(OperationPlan o .WithInitializer(SyntaxFactory.EqualsValueClause(EmitConcatenation(operation)))))); yield return SyntaxFactory.IfStatement( SyntaxFactory.IsPatternExpression( - SyntaxFactory.IdentifierName("options"), + SyntaxFactory.IdentifierName("request"), SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), SyntaxFactory.Block(SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName("path")))); yield return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration( @@ -153,13 +153,13 @@ private static IEnumerable EmitQueryComposition(OperationPlan o .WithInitializer(SyntaxFactory.EqualsValueClause(SyntaxFactory.ObjectCreationExpression( TypeSyntaxEmitter.EmitNamed("QueryStringBuilder")) .WithArgumentList(SyntaxFactory.ArgumentList())))))); - foreach (var property in operation.Options!.Properties) + foreach (var property in operation.QueryRequest!.Properties) { yield return SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), QueryAddMethod(property.Kind)), SyntaxFactory.Argument(StringLiteral(property.WireName)), SyntaxFactory.Argument(EmissionSyntax.MemberAccess( - SyntaxFactory.IdentifierName("options"), + SyntaxFactory.IdentifierName("request"), property.PropertyName)))); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs index bc4e20989..74bf810a0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs @@ -15,7 +15,7 @@ public static IReadOnlyList Emit(EmitPlan plan) if (plan.Clients.Count > 0) { sources.Add(RoutesEmitter.Emit(plan.Clients)); - sources.AddRange(OptionsEmitter.Emit(plan.Clients)); + sources.AddRange(QueryRequestEmitter.Emit(plan.Clients)); sources.AddRange(EnvelopeDtoEmitter.Emit(plan.Clients)); sources.AddRange(EnvelopeEmitter.Emit(plan.Clients)); sources.AddRange(ResponseAdapterEmitter.Emit(plan.Clients)); From 251b483ddff2c3e95bd619e59dd618a44accd318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 14:08:02 +0300 Subject: [PATCH 21/47] refactor(sdk): migrate to the feature-slice layout (Q84) The shipped SDK now slices by client family with flat public namespaces: Sessions/ and Health/ hold each family's clients, envelopes, and query requests; Pagination/ holds the ListRequest/ListOrder/ListCursor spine; Models/, Internal/, and the root client with the response/exception spine stay put. Namespaces remain OpenCode.Sdk and OpenCode.Sdk.Models - folders are placement, a namespace is API surface - so the PublicApi baseline is untouched by this change. Emission is name-blind: envelope and query-request files derive their folder from the operation's RouteContainerName, family clients from the new ClientPlan.ContainerName the binder sets from the curated client name; the root client, OpenCodeRoutes, and the registry keep their homes. The writer's allowlist learns the family folders fail-closed through the new GenerationWriteRequest (admitted folders come from the plan's container names, one level deep, never shadowing a static directory); manifest entries validate shape only so a retired family's files stay deletable behind the provenance wall. WriteAsync's six parameters fold into the request record per the signature-hygiene rule. IDE0130 is arbitrated for the SDK's public folders and their test mirrors per the standing per-rule pattern; contract tests and the parent-filter spine test move under Sessions/ with flat test namespaces. Manifest paths churned once; stale root-level copies deleted in the same regen. --- .editorconfig | 11 + docs/ROADMAP.md | 2 +- src/OpenCode.Sdk/.generated-manifest.json | 20 +- .../{ => Health}/HealthResponse.cs | 0 .../{ => Pagination}/ListCursor.cs | 0 .../{ => Pagination}/ListOrder.cs | 0 .../{ => Pagination}/ListRequest.cs | 0 .../{ => Sessions}/MessageListRequest.cs | 0 .../{ => Sessions}/MessageListResponse.cs | 0 .../{ => Sessions}/SessionClient.cs | 0 .../{ => Sessions}/SessionCreateResponse.cs | 0 .../{ => Sessions}/SessionListRequest.cs | 0 .../{ => Sessions}/SessionListResponse.cs | 0 .../{ => Sessions}/SessionMessageResponse.cs | 0 .../{ => Sessions}/SessionParentFilter.cs | 0 .../{ => Sessions}/SessionResponse.cs | 0 .../{ => Sessions}/SessionsClient.cs | 0 .../SessionClientContractTests.cs | 0 .../SessionParentFilterTests.cs | 0 .../SessionsClientContractTests.cs | 0 .../Binding/OperationPlanBinderTests.cs | 4 + .../Output/GenerationWriterTests.cs | 200 ++++++++++-------- ...ce_Virtual_Delegating_Clients.verified.txt | 4 +- ...ce_Guarded_Response_Envelopes.verified.txt | 12 +- ...der_The_Query_Request_Records.verified.txt | 4 +- .../Support/EmitterPlanFixture.cs | 2 + .../Generator/Binding/Models/ClientPlan.cs | 3 + .../Generator/Binding/OperationPlanBinder.cs | 2 + .../Generator/Emission/ClientEmitter.cs | 4 +- .../Generator/Emission/EnvelopeEmitter.cs | 2 +- .../Generator/Emission/QueryRequestEmitter.cs | 2 +- .../Generator/GenerationCoordinator.cs | 23 +- .../Output/Abstractions/IGenerationWriter.cs | 10 +- .../Output/GenerationWriteRequest.cs | 65 ++++++ .../Output/GenerationWriter.cs | 91 +++++--- 35 files changed, 307 insertions(+), 154 deletions(-) rename src/OpenCode.Sdk/{ => Health}/HealthResponse.cs (100%) rename src/OpenCode.Sdk/{ => Pagination}/ListCursor.cs (100%) rename src/OpenCode.Sdk/{ => Pagination}/ListOrder.cs (100%) rename src/OpenCode.Sdk/{ => Pagination}/ListRequest.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/MessageListRequest.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/MessageListResponse.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionClient.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionCreateResponse.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionListRequest.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionListResponse.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionMessageResponse.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionParentFilter.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionResponse.cs (100%) rename src/OpenCode.Sdk/{ => Sessions}/SessionsClient.cs (100%) rename tests/OpenCode.Sdk.Tests/{ => Sessions}/SessionClientContractTests.cs (100%) rename tests/OpenCode.Sdk.Tests/{ => Sessions}/SessionParentFilterTests.cs (100%) rename tests/OpenCode.Sdk.Tests/{ => Sessions}/SessionsClientContractTests.cs (100%) create mode 100644 tools/OpenCode.Sdk.Tools/Output/GenerationWriteRequest.cs diff --git a/.editorconfig b/.editorconfig index 6cf65c4e7..8213b0370 100644 --- a/.editorconfig +++ b/.editorconfig @@ -798,3 +798,14 @@ dotnet_diagnostic.S1694.severity = none # file. [src/OpenCode.Sdk/OpenCodeRoutes.cs] dotnet_diagnostic.CA1034.severity = none + +# The shipped SDK slices by client family with flat public namespaces (maintainer, +# 2026-08-14): client-family folders are placement, a namespace is API surface, and the +# 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] +dotnet_diagnostic.IDE0130.severity = none + +[tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination}/**.cs] +dotnet_diagnostic.IDE0130.severity = none diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b0373523e..aaae51e29 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -40,7 +40,7 @@ duplicate collapses through the new `schemaAliases` curation. Demonstrated live against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor round-tripped). The #20 decision landed (blank explicit passwords refuse; `null` keeps the environment fallback) and #25 closed keep. The alignment batch is underway: the uniform -`*Request` rename (Q83) has landed; next are the feature-slice layout migration (Q84) and +`*Request` rename (Q83) and the feature-slice layout migration (Q84) have landed; next is the Extensions bring-up (Q85) (`agents/handover-prompts/HANDOFF-2026-08-14-2.md`); further breadth batches follow. diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index a925e1ba4..f76aae980 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -1,6 +1,6 @@ { "files": [ - "HealthResponse.cs", + "Health/HealthResponse.cs", "Internal/ResponseAdapters/HealthResponseAdapter.cs", "Internal/ResponseAdapters/MessageListResponseAdapter.cs", "Internal/ResponseAdapters/SessionCreateResponseAdapter.cs", @@ -30,8 +30,6 @@ "Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs", "Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs", "Internal/Serialization/UnknownToolContentJsonConverter.cs", - "MessageListRequest.cs", - "MessageListResponse.cs", "Models/FileDiffInfo.cs", "Models/FileDiffInfoStatus.cs", "Models/InvalidCursorError.cs", @@ -121,12 +119,14 @@ "Models/UnknownToolContent.cs", "OpenCodeClient.cs", "OpenCodeRoutes.cs", - "SessionClient.cs", - "SessionCreateResponse.cs", - "SessionListRequest.cs", - "SessionListResponse.cs", - "SessionMessageResponse.cs", - "SessionResponse.cs", - "SessionsClient.cs" + "Sessions/MessageListRequest.cs", + "Sessions/MessageListResponse.cs", + "Sessions/SessionClient.cs", + "Sessions/SessionCreateResponse.cs", + "Sessions/SessionListRequest.cs", + "Sessions/SessionListResponse.cs", + "Sessions/SessionMessageResponse.cs", + "Sessions/SessionResponse.cs", + "Sessions/SessionsClient.cs" ] } diff --git a/src/OpenCode.Sdk/HealthResponse.cs b/src/OpenCode.Sdk/Health/HealthResponse.cs similarity index 100% rename from src/OpenCode.Sdk/HealthResponse.cs rename to src/OpenCode.Sdk/Health/HealthResponse.cs diff --git a/src/OpenCode.Sdk/ListCursor.cs b/src/OpenCode.Sdk/Pagination/ListCursor.cs similarity index 100% rename from src/OpenCode.Sdk/ListCursor.cs rename to src/OpenCode.Sdk/Pagination/ListCursor.cs diff --git a/src/OpenCode.Sdk/ListOrder.cs b/src/OpenCode.Sdk/Pagination/ListOrder.cs similarity index 100% rename from src/OpenCode.Sdk/ListOrder.cs rename to src/OpenCode.Sdk/Pagination/ListOrder.cs diff --git a/src/OpenCode.Sdk/ListRequest.cs b/src/OpenCode.Sdk/Pagination/ListRequest.cs similarity index 100% rename from src/OpenCode.Sdk/ListRequest.cs rename to src/OpenCode.Sdk/Pagination/ListRequest.cs diff --git a/src/OpenCode.Sdk/MessageListRequest.cs b/src/OpenCode.Sdk/Sessions/MessageListRequest.cs similarity index 100% rename from src/OpenCode.Sdk/MessageListRequest.cs rename to src/OpenCode.Sdk/Sessions/MessageListRequest.cs diff --git a/src/OpenCode.Sdk/MessageListResponse.cs b/src/OpenCode.Sdk/Sessions/MessageListResponse.cs similarity index 100% rename from src/OpenCode.Sdk/MessageListResponse.cs rename to src/OpenCode.Sdk/Sessions/MessageListResponse.cs diff --git a/src/OpenCode.Sdk/SessionClient.cs b/src/OpenCode.Sdk/Sessions/SessionClient.cs similarity index 100% rename from src/OpenCode.Sdk/SessionClient.cs rename to src/OpenCode.Sdk/Sessions/SessionClient.cs diff --git a/src/OpenCode.Sdk/SessionCreateResponse.cs b/src/OpenCode.Sdk/Sessions/SessionCreateResponse.cs similarity index 100% rename from src/OpenCode.Sdk/SessionCreateResponse.cs rename to src/OpenCode.Sdk/Sessions/SessionCreateResponse.cs diff --git a/src/OpenCode.Sdk/SessionListRequest.cs b/src/OpenCode.Sdk/Sessions/SessionListRequest.cs similarity index 100% rename from src/OpenCode.Sdk/SessionListRequest.cs rename to src/OpenCode.Sdk/Sessions/SessionListRequest.cs diff --git a/src/OpenCode.Sdk/SessionListResponse.cs b/src/OpenCode.Sdk/Sessions/SessionListResponse.cs similarity index 100% rename from src/OpenCode.Sdk/SessionListResponse.cs rename to src/OpenCode.Sdk/Sessions/SessionListResponse.cs diff --git a/src/OpenCode.Sdk/SessionMessageResponse.cs b/src/OpenCode.Sdk/Sessions/SessionMessageResponse.cs similarity index 100% rename from src/OpenCode.Sdk/SessionMessageResponse.cs rename to src/OpenCode.Sdk/Sessions/SessionMessageResponse.cs diff --git a/src/OpenCode.Sdk/SessionParentFilter.cs b/src/OpenCode.Sdk/Sessions/SessionParentFilter.cs similarity index 100% rename from src/OpenCode.Sdk/SessionParentFilter.cs rename to src/OpenCode.Sdk/Sessions/SessionParentFilter.cs diff --git a/src/OpenCode.Sdk/SessionResponse.cs b/src/OpenCode.Sdk/Sessions/SessionResponse.cs similarity index 100% rename from src/OpenCode.Sdk/SessionResponse.cs rename to src/OpenCode.Sdk/Sessions/SessionResponse.cs diff --git a/src/OpenCode.Sdk/SessionsClient.cs b/src/OpenCode.Sdk/Sessions/SessionsClient.cs similarity index 100% rename from src/OpenCode.Sdk/SessionsClient.cs rename to src/OpenCode.Sdk/Sessions/SessionsClient.cs diff --git a/tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs similarity index 100% rename from tests/OpenCode.Sdk.Tests/SessionClientContractTests.cs rename to tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs diff --git a/tests/OpenCode.Sdk.Tests/SessionParentFilterTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionParentFilterTests.cs similarity index 100% rename from tests/OpenCode.Sdk.Tests/SessionParentFilterTests.cs rename to tests/OpenCode.Sdk.Tests/Sessions/SessionParentFilterTests.cs diff --git a/tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs similarity index 100% rename from tests/OpenCode.Sdk.Tests/SessionsClientContractTests.cs rename to tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 1e3f5c4a2..2724d48a0 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -43,8 +43,11 @@ await Assert.That(health.ErrorMap.Statuses.Select(static status => status.Status await Assert.That(health.ErrorMap.Statuses[0].Tags.Single().TypeName).IsEqualTo("InvalidRequestError"); await Assert.That(health.ErrorMap.Statuses[1].Tags.Single().Tag).IsEqualTo("UnauthorizedError"); + await Assert.That(root.ContainerName).IsNull(); + var sessions = plan.Clients.Single(static client => client.Role == ClientRole.Collection); await Assert.That(sessions.Name).IsEqualTo("SessionsClient"); + await Assert.That(sessions.ContainerName).IsEqualTo("Sessions"); await Assert.That(sessions.Operations.Select(static operation => operation.MethodName) .SequenceEqual(["CreateSessionAsync", "ListSessionsAsync"], StringComparer.Ordinal)).IsTrue(); await Assert.That(sessions.HandleFactory!.MethodName).IsEqualTo("GetSessionClient"); @@ -81,6 +84,7 @@ public async Task Bind_Should_Create_The_Selected_Pinned_Handle_Plans() var session = plan.Clients.Single(static client => client.Role == ClientRole.Handle); await Assert.That(session.Name).IsEqualTo("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) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs index cc4abd832..55392bf8c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs @@ -16,11 +16,7 @@ public async Task WriteAsync_Should_Refuse_Unsafe_Source_Paths() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("../escape.cs", "unsafe")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("../escape.cs", "unsafe")]), CancellationToken.None)) .Throws(); @@ -37,11 +33,7 @@ public async Task WriteAsync_Should_Refuse_Unsafe_Manifest_Paths() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [], - partialMarkerContent: null, - verify: false, + Request([]), CancellationToken.None)) .Throws(); @@ -60,11 +52,7 @@ public async Task WriteAsync_Should_Refuse_Symbolic_Links_In_Owned_Paths() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Escape.cs", "unsafe")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Escape.cs", "unsafe")]), CancellationToken.None)) .Throws(); @@ -83,11 +71,7 @@ public async Task WriteAsync_Should_Refuse_Case_Only_Owned_Path_Changes() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var exception = await Assert.That(async () => await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/widget.cs", "new")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/widget.cs", "new")]), CancellationToken.None)) .Throws(); @@ -108,11 +92,7 @@ public async Task WriteAsync_Should_Delete_Only_Stale_Manifest_Files() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var result = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Fresh.cs", "fresh")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Fresh.cs", "fresh")]), CancellationToken.None); await Assert.That(fileSystem.File.Exists(stalePath)).IsFalse(); @@ -138,20 +118,8 @@ await currentFileSystem.File.WriteAllTextAsync( var writer = new GenerationWriter(fileSystem, formatter); var sources = new[] { GenerationTestData.Source(relativePath, "public record Example;\n"), }; - _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: false, - CancellationToken.None); - var verify = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: true, - CancellationToken.None); + _ = await writer.WriteAsync(Request(sources), CancellationToken.None); + var verify = await writer.WriteAsync(Request(sources, verify: true), CancellationToken.None); await Assert.That(formatter.CallCount).IsEqualTo(2); await Assert.That(formatter.SourcePaths).Contains(relativePath); @@ -165,23 +133,11 @@ public async Task WriteAsync_Should_Report_And_Repair_Verify_Drift() var fileSystem = GenerationTestData.CreateFileSystem(); var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var sources = new[] { GenerationTestData.Source(relativePath, "expected\n"), }; - _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: false, - CancellationToken.None); + _ = await writer.WriteAsync(Request(sources), CancellationToken.None); var outputPath = fileSystem.Path.Combine(GenerationTestData.OutputRoot, relativePath); await fileSystem.File.WriteAllTextAsync(outputPath, GenerationTestData.OwnedContent("drift\n"), CancellationToken.None); - var result = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - sources, - partialMarkerContent: null, - verify: true, - CancellationToken.None); + var result = await writer.WriteAsync(Request(sources, verify: true), CancellationToken.None); await Assert.That(result.HasChanges).IsTrue(); await Assert.That(result.ChangedPaths).Contains(relativePath); @@ -196,20 +152,97 @@ public async Task WriteAsync_Should_Admit_Root_And_Adapter_Sources() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); var result = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, + Request( [ GenerationTestData.Source("ExampleClient.cs", "client"), GenerationTestData.Source("Internal/ResponseAdapters/ExampleAdapter.cs", "adapter"), - ], - partialMarkerContent: null, - verify: false, + ]), CancellationToken.None); await Assert.That(result.CreatedPaths).Contains("ExampleClient.cs"); await Assert.That(result.CreatedPaths).Contains("Internal/ResponseAdapters/ExampleAdapter.cs"); } + [Test] + public async Task WriteAsync_Should_Admit_Family_Folder_Sources() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var result = await writer.WriteAsync( + Request( + [GenerationTestData.Source("Sessions/SessionsClient.cs", "client")], + familyFolders: ["Sessions"]), + CancellationToken.None); + + await Assert.That(result.CreatedPaths).Contains("Sessions/SessionsClient.cs"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_A_Source_Outside_The_Admitted_Family_Folders() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + Request( + [GenerationTestData.Source("Widgets/WidgetsClient.cs", "client")], + familyFolders: ["Sessions"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("outside the owned source directories"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_A_Nested_Family_Folder_Source() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + Request( + [GenerationTestData.Source("Sessions/Deep/File.cs", "nested")], + familyFolders: ["Sessions"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("outside the owned source directories"); + } + + [Test] + public async Task WriteAsync_Should_Delete_Stale_Sources_Of_A_Retired_Family_Folder() + { + const string stalePath = "src/OpenCode.Sdk/Widgets/Old.cs"; + var fileSystem = GenerationTestData.CreateFileSystem(); + var manifest = JsonSerializer.Serialize(new GenerationManifest { Files = ["Widgets/Old.cs"], }); + fileSystem.Initialize().With( + new FileDescription(GenerationTestData.ManifestPath, manifest), + new FileDescription(stalePath, GenerationTestData.OwnedContent("stale"))); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var result = await writer.WriteAsync( + Request([], familyFolders: ["Sessions"]), + CancellationToken.None); + + await Assert.That(fileSystem.File.Exists(stalePath)).IsFalse(); + await Assert.That(result.DeletedPaths).Contains("Widgets/Old.cs"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_A_Family_Folder_Shadowing_A_Static_Owned_Directory() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert.That(async () => await writer.WriteAsync( + Request([], familyFolders: ["Models"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("Models"); + } + [Test] public async Task WriteAsync_Should_Refuse_Overwriting_An_Unmanifested_File() { @@ -219,11 +252,7 @@ public async Task WriteAsync_Should_Refuse_Overwriting_An_Unmanifested_File() var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("ExampleClient.cs", "generated")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("ExampleClient.cs", "generated")]), CancellationToken.None)) .Throws(); @@ -244,11 +273,7 @@ public async Task WriteAsync_Should_Refuse_Overwriting_A_Manifest_Entry_Without_ var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Example.cs", "generated")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Example.cs", "generated")]), CancellationToken.None)) .Throws(); @@ -267,11 +292,7 @@ public async Task WriteAsync_Should_Refuse_Deleting_A_Manifest_Entry_Without_Pro var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [GenerationTestData.Source("Models/Fresh.cs", "generated")], - partialMarkerContent: null, - verify: false, + Request([GenerationTestData.Source("Models/Fresh.cs", "generated")]), CancellationToken.None)) .Throws(); @@ -292,11 +313,7 @@ public async Task WriteAsync_Should_Refuse_Headerless_Generated_Sources() var exception = await Assert .That(async () => _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [headerless], - partialMarkerContent: null, - verify: false, + Request([headerless]), CancellationToken.None)) .Throws(); @@ -310,21 +327,24 @@ public async Task WriteAsync_Should_Create_And_Remove_The_Partial_Marker() var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); _ = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [], - "Pending operations: 3.\n", - verify: false, - CancellationToken.None); - var removal = await writer.WriteAsync( - GenerationTestData.OutputRoot, - GenerationTestData.ProjectPath, - [], - partialMarkerContent: null, - verify: false, + Request([], partialMarkerContent: "Pending operations: 3.\n"), CancellationToken.None); + var removal = await writer.WriteAsync(Request([]), CancellationToken.None); await Assert.That(fileSystem.File.Exists(GenerationTestData.MarkerPath)).IsFalse(); await Assert.That(removal.DeletedPaths).Contains(".generation-incomplete"); } + + /// Assembles one write request over the shared test roots; flags default off. + private static GenerationWriteRequest Request(IReadOnlyList sources, + IReadOnlyList? familyFolders = null, string? partialMarkerContent = null, bool verify = false) => + new() + { + OutputRoot = GenerationTestData.OutputRoot, + ProjectPath = GenerationTestData.ProjectPath, + Sources = sources, + FamilyFolders = familyFolders ?? [], + PartialMarkerContent = partialMarkerContent, + Verify = verify, + }; } 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 1f830314f..119bfe699 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 @@ -96,7 +96,7 @@ public class OpenCodeClient : IDisposable } } -// File: WidgetClient.cs +// File: Widgets/WidgetClient.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; @@ -164,7 +164,7 @@ public class WidgetClient } } -// File: WidgetsClient.cs +// File: Widgets/WidgetsClient.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; 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 20c8dd378..5295752cf 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 @@ -1,4 +1,4 @@ -// File: PingResponse.cs +// File: Ping/PingResponse.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; @@ -60,7 +60,7 @@ public sealed record PingResponse : OpenCodeResponse } } -// File: WidgetCreateResponse.cs +// File: Widgets/WidgetCreateResponse.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; @@ -122,7 +122,7 @@ public sealed record WidgetCreateResponse : OpenCodeResponse } } -// File: WidgetItemListResponse.cs +// File: Widgets/WidgetItemListResponse.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; @@ -190,7 +190,7 @@ public sealed record WidgetItemListResponse : OpenCodeResponse } } -// File: WidgetItemResponse.cs +// File: Widgets/WidgetItemResponse.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; @@ -252,7 +252,7 @@ public sealed record WidgetItemResponse : OpenCodeResponse } } -// File: WidgetListResponse.cs +// File: Widgets/WidgetListResponse.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; @@ -320,7 +320,7 @@ public sealed record WidgetListResponse : OpenCodeResponse } } -// File: WidgetOverviewResponse.cs +// File: Widgets/WidgetOverviewResponse.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; 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 dd752f6bd..b962e6df1 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 @@ -1,4 +1,4 @@ -// File: ItemListRequest.cs +// File: Widgets/ItemListRequest.cs // 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; @@ -9,7 +9,7 @@ public sealed record ItemListRequest : ListRequest { } -// File: WidgetListRequest.cs +// File: Widgets/WidgetListRequest.cs // 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; diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 884de4e1a..241cb825e 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -74,6 +74,7 @@ public static IReadOnlyList CreateClientPlans() => Name = "WidgetClient", Namespace = "OpenCode.Sdk", Role = ClientRole.Handle, + ContainerName = "Widgets", SubClients = [], HandleParameter = CreateWidgetParameter(), Operations = [CreateItemOperation(), CreateItemListOperation()], @@ -83,6 +84,7 @@ public static IReadOnlyList CreateClientPlans() => Name = "WidgetsClient", Namespace = "OpenCode.Sdk", Role = ClientRole.Collection, + ContainerName = "Widgets", SubClients = [], HandleFactory = new HandleFactoryPlan { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs index b69f32368..371e7241c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ClientPlan.cs @@ -8,6 +8,9 @@ internal sealed record ClientPlan public required ClientRole Role { get; init; } + /// Gets the feature folder the family's files live in; keeps the root client at the project root. + public string? ContainerName { get; init; } + /// Gets the group client properties exposed by the root client. public required IReadOnlyList SubClients { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 7487f31f8..2cddcb95c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -152,6 +152,7 @@ private IEnumerable CreateGroupClients(IReadOnlyList Name = collectionName, Namespace = ClientNamespace, Role = ClientRole.Collection, + ContainerName = row.ClientName, SubClients = [], HandleFactory = handleParameter is null ? null : new HandleFactoryPlan { @@ -169,6 +170,7 @@ private IEnumerable CreateGroupClients(IReadOnlyList Name = row.HandleName!, Namespace = ClientNamespace, Role = ClientRole.Handle, + ContainerName = row.ClientName, SubClients = [], HandleParameter = handleParameter, Operations = diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs index d35787299..aa65d4164 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs @@ -73,7 +73,9 @@ private static GeneratedSource EmitClient(ClientPlan client) "OpenCode.Sdk.Models", ], [declaration]); - return EmissionSyntax.CreateSource($"{client.Name}.cs", unit); + return EmissionSyntax.CreateSource( + client.ContainerName is null ? $"{client.Name}.cs" : $"{client.ContainerName}/{client.Name}.cs", + unit); } private static string Summary(ClientPlan client) => client.Role switch diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index b55a86d67..c6fd5d857 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -59,7 +59,7 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) "OpenCode.Sdk", ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"], [declaration]); - return EmissionSyntax.CreateSource($"{envelope.ResponseTypeName}.cs", unit); + return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{envelope.ResponseTypeName}.cs", unit); } private static TypeSyntax PayloadType(EnvelopePlan envelope) => envelope.Kind is EnvelopeKind.CursorList diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs index 548918bba..f4abda1bc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/QueryRequestEmitter.cs @@ -49,7 +49,7 @@ .. queryRequest.Properties } var unit = EmissionSyntax.CompilationUnit("OpenCode.Sdk", [], [declaration]); - return EmissionSyntax.CreateSource($"{queryRequest.TypeName}.cs", unit); + return EmissionSyntax.CreateSource($"{operation.RouteContainerName}/{queryRequest.TypeName}.cs", unit); } private static PropertyDeclarationSyntax EmitProperty(QueryPropertyPlan property) diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs index b7b0ec2ef..8f6c24f5c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs @@ -4,6 +4,7 @@ using OpenCode.Sdk.Tools.Generator.Binding.Abstractions; using OpenCode.Sdk.Tools.Generator.Emission; using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions; +using OpenCode.Sdk.Tools.Output; using OpenCode.Sdk.Tools.Output.Abstractions; namespace OpenCode.Sdk.Tools.Generator; @@ -39,13 +40,25 @@ public async Task GenerateAsync(GenerationRequest request, Can var marker = pending.Length > 0 ? CreatePartialMarker(plan.SelectedOperationIds, pending) : null; + // The admitted family folders are the plan's own container names — never an open glob. + var familyFolders = plan.Clients + .SelectMany(static client => client.Operations) + .Select(static operation => operation.RouteContainerName) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + var writeResult = await _writer .WriteAsync( - request.OutputRoot, - request.ProjectPath, - SourceEmitter.Emit(plan), - marker, - request.Verify, + new GenerationWriteRequest + { + OutputRoot = request.OutputRoot, + ProjectPath = request.ProjectPath, + Sources = SourceEmitter.Emit(plan), + FamilyFolders = familyFolders, + PartialMarkerContent = marker, + Verify = request.Verify, + }, cancellationToken) .ConfigureAwait(false); diff --git a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs index 96b1a9cdc..5daba05d9 100644 --- a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs +++ b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs @@ -1,14 +1,6 @@ -using OpenCode.Sdk.Tools.Generator.Emission; - namespace OpenCode.Sdk.Tools.Output.Abstractions; internal interface IGenerationWriter { - public Task WriteAsync( - string outputRoot, - string projectPath, - IReadOnlyList sources, - string? partialMarkerContent, - bool verify, - CancellationToken cancellationToken); + public Task WriteAsync(GenerationWriteRequest request, CancellationToken cancellationToken); } diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriteRequest.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriteRequest.cs new file mode 100644 index 000000000..9f15c6d77 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriteRequest.cs @@ -0,0 +1,65 @@ +using OpenCode.Sdk.Tools.Generator.Emission; + +namespace OpenCode.Sdk.Tools.Output; + +/// One generation write: the sources, their admitted family folders, and the run flags. +internal sealed record GenerationWriteRequest +{ + public required string OutputRoot + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } + + public required string ProjectPath + { + get; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + } + } + + public required IReadOnlyList Sources + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } + + /// Gets the client-family folders new sources may live in, derived from the plan's container names. + public required IReadOnlyList FamilyFolders + { + get; + init + { + ArgumentNullException.ThrowIfNull(value); + field = Array.AsReadOnly([.. value]); + } + } + + /// Gets the partial-generation marker content, or when generation is complete. + public string? PartialMarkerContent + { + get; + init + { + if (value is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + } + + field = value; + } + } + + public required bool Verify { get; init; } +} diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs index f7eeee55d..334575d08 100644 --- a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs @@ -15,24 +15,13 @@ internal sealed class GenerationWriter(IFileSystem fileSystem, IProjectFormatter private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); private readonly IProjectFormatter _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); - public async Task WriteAsync( - string outputRoot, - string projectPath, - IReadOnlyList sources, - string? partialMarkerContent, - bool verify, - CancellationToken cancellationToken) + public async Task WriteAsync(GenerationWriteRequest request, CancellationToken cancellationToken) { - ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot); - ArgumentException.ThrowIfNullOrWhiteSpace(projectPath); - ArgumentNullException.ThrowIfNull(sources); - if (partialMarkerContent is not null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(partialMarkerContent); - } - + ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); - var newSources = ValidateSources(outputRoot, sources); + var outputRoot = request.OutputRoot; + var familyFolders = ValidateFamilyFolders(request.FamilyFolders); + var newSources = ValidateSources(outputRoot, request.Sources, familyFolders); var oldManifest = await LoadManifestAsync(outputRoot, cancellationToken).ConfigureAwait(false); var oldPaths = ValidateManifest(outputRoot, oldManifest); RefuseCaseOnlyPathChanges(oldPaths, newSources.Keys); @@ -50,15 +39,42 @@ public async Task WriteAsync( await WriteSourcesAsync(outputRoot, newSources, cancellationToken).ConfigureAwait(false); DeleteStaleSources(outputRoot, oldPaths, newSources.Keys); await WriteManifestAsync(outputRoot, newSources.Keys, cancellationToken).ConfigureAwait(false); - await WriteMarkerAsync(outputRoot, partialMarkerContent, cancellationToken).ConfigureAwait(false); + await WriteMarkerAsync(outputRoot, request.PartialMarkerContent, cancellationToken).ConfigureAwait(false); var sourcePaths = newSources.Keys.Order(StringComparer.Ordinal).ToArray(); - await _formatter.FormatAsync(projectPath, sourcePaths, cancellationToken).ConfigureAwait(false); + await _formatter.FormatAsync(request.ProjectPath, sourcePaths, cancellationToken).ConfigureAwait(false); var after = await SnapshotAsync(outputRoot, trackedPaths, cancellationToken).ConfigureAwait(false); - return Compare(before, after, verify); + return Compare(before, after, request.Verify); + } + + /// Admitted family folders are single plain segments that never shadow a statically owned directory. + private static HashSet ValidateFamilyFolders(IReadOnlyList familyFolders) + { + var result = new HashSet(StringComparer.Ordinal); + foreach (var folder in familyFolders) + { + if (string.IsNullOrWhiteSpace(folder) || folder is "." or ".." + || folder.Contains('/', StringComparison.Ordinal) || folder.Contains('\\', StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Family folder '{folder}' is unsafe."); + } + + if (folder is "Models" or "Internal" or "Properties") + { + throw new InvalidOperationException($"Family folder '{folder}' shadows a statically owned directory."); + } + + if (!result.Add(folder)) + { + throw new InvalidOperationException($"Family folder '{folder}' is duplicated."); + } + } + + return result; } - private Dictionary ValidateSources(string outputRoot, IReadOnlyList sources) + private Dictionary ValidateSources(string outputRoot, IReadOnlyList sources, + HashSet familyFolders) { var result = new Dictionary(OwnedPathComparer); foreach (var source in sources) @@ -69,7 +85,7 @@ private Dictionary ValidateSources(string outputRoot, I } ValidateRelativePath(outputRoot, source.RelativePath); - ValidateGeneratedSourcePath(source.RelativePath); + ValidateGeneratedSourcePath(source.RelativePath, familyFolders); // A headerless source would deadlock the next run's provenance requirement. if (!GenerationProvenance.HasHeader(Encoding.UTF8.GetString(source.Utf8Source.Span))) @@ -112,7 +128,7 @@ private HashSet ValidateManifest(string outputRoot, GenerationManifest m foreach (var relativePath in manifest.Files) { ValidateRelativePath(outputRoot, relativePath); - ValidateGeneratedSourcePath(relativePath); + ValidateManifestSourcePath(relativePath); if (!result.Add(relativePath)) { throw new InvalidOperationException($"Generation manifest path '{relativePath}' is duplicated."); @@ -210,18 +226,41 @@ private void ValidateRelativePath(string outputRoot, string relativePath) _ = ResolveOwnedPath(outputRoot, relativePath); } - private static void ValidateGeneratedSourcePath(string relativePath) + /// New sources land in the root, a statically owned directory, or an admitted family folder — one level deep. + private static void ValidateGeneratedSourcePath(string relativePath, HashSet familyFolders) { if (!relativePath.EndsWith(".cs", StringComparison.Ordinal) || !(!relativePath.Contains('/', StringComparison.Ordinal) - || relativePath.StartsWith("Models/", StringComparison.Ordinal) - || relativePath.StartsWith("Internal/Serialization/", StringComparison.Ordinal) - || relativePath.StartsWith("Internal/ResponseAdapters/", StringComparison.Ordinal))) + || IsStaticallyOwnedPath(relativePath) + || IsAdmittedFamilyPath(relativePath, familyFolders))) { throw new InvalidOperationException($"Generated source path '{relativePath}' is outside the owned source directories."); } } + /// + /// Manifest entries validate shape, not family membership: a retired family's files must + /// stay deletable, and the provenance requirement still guards every overwrite and delete. + /// + private static void ValidateManifestSourcePath(string relativePath) + { + if (!relativePath.EndsWith(".cs", StringComparison.Ordinal) + || !(!relativePath.Contains('/', StringComparison.Ordinal) + || IsStaticallyOwnedPath(relativePath) + || relativePath.Split('/').Length is 2)) + { + throw new InvalidOperationException($"Generation manifest path '{relativePath}' is outside the owned source directories."); + } + } + + private static bool IsStaticallyOwnedPath(string relativePath) => + relativePath.StartsWith("Models/", StringComparison.Ordinal) + || relativePath.StartsWith("Internal/Serialization/", StringComparison.Ordinal) + || relativePath.StartsWith("Internal/ResponseAdapters/", StringComparison.Ordinal); + + private static bool IsAdmittedFamilyPath(string relativePath, HashSet familyFolders) => + relativePath.Split('/') is [var folder, _] && familyFolders.Contains(folder); + /// A new path that already exists on disk belongs to somebody else until the manifest owns it. private void RefuseUnmanifestedOverwrites(string outputRoot, HashSet oldPaths, IEnumerable newPaths) { From 1680d7ba3b48623992479a1c8b5b42f10c51a111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 14:18:07 +0300 Subject: [PATCH 22/47] feat(extensions): bring up AddOpenCode registration (Q85) OpenCode.Sdk.Extensions gains its first surface: three AddOpenCode overloads mirroring the client constructors - (Uri), (Uri, Action?), and a caller-owned HttpClient with a required configure action (the endpoint is required on that path). Each registers the root OpenCodeClient as a singleton the container disposes, plus a SessionsClient registration resolved from the root so consumers inject sub-clients directly. Extension methods live in the Microsoft.Extensions.DependencyInjection namespace per the recorded companion-package convention, with the IDE0130 arbitration scoped to that one file. The only new dependency is Microsoft.Extensions.DependencyInjection .Abstractions per the research doc 06 policy; IHttpClientFactory integration stays out of this first cut deliberately. The new OpenCode.Sdk.Extensions.Tests project mirrors the layout and covers each overload's composition, sub-client resolution, the configure action, the caller-owned transport path against a canned health fixture, and the input guards. Registrations stay hand-written; the research log records the generator-emission trigger for when client families multiply. --- .editorconfig | 7 ++ Directory.Packages.props | 1 + OpenCode.slnx | 1 + docs/ROADMAP.md | 15 +-- docs/research/00-research-log.md | 14 +++ .../OpenCode.Sdk.Extensions.csproj | 14 +++ .../OpenCodeServiceCollectionExtensions.cs | 63 ++++++++++ .../App.net472.config | 6 + .../Fixtures/known-health.json | 1 + .../OpenCode.Sdk.Extensions.Tests.csproj | 39 ++++++ ...penCodeServiceCollectionExtensionsTests.cs | 113 ++++++++++++++++++ .../Support/FixtureLoader.cs | 22 ++++ .../Support/RecordingHttpHandler.cs | 26 ++++ 13 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/App.net472.config create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs diff --git a/.editorconfig b/.editorconfig index 8213b0370..ec7d624e8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -809,3 +809,10 @@ dotnet_diagnostic.IDE0130.severity = none [tests/OpenCode.Sdk.Tests/{Sessions,Health,Pagination}/**.cs] dotnet_diagnostic.IDE0130.severity = none + +# DI extension methods live in the Microsoft.Extensions.DependencyInjection namespace so +# consumers get AddOpenCode without an extra using - the ecosystem convention every +# companion DI package follows (recorded decision, 2026-08-08). Winner: the ecosystem +# namespace contract; IDE0130 remains error for every other Extensions file. +[src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs] +dotnet_diagnostic.IDE0130.severity = none diff --git a/Directory.Packages.props b/Directory.Packages.props index 769b76b8a..86a423c61 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/OpenCode.slnx b/OpenCode.slnx index 98599005b..9df1a1603 100644 --- a/OpenCode.slnx +++ b/OpenCode.slnx @@ -16,6 +16,7 @@ + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index aaae51e29..d1d9da24d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,10 +39,11 @@ and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `Invalid duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14 against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor round-tripped). The #20 decision landed (blank explicit passwords refuse; `null` keeps the -environment fallback) and #25 closed keep. The alignment batch is underway: the uniform -`*Request` rename (Q83) and the feature-slice layout migration (Q84) have landed; next is -the Extensions bring-up (Q85) (`agents/handover-prompts/HANDOFF-2026-08-14-2.md`); further -breadth batches follow. +environment fallback) and #25 closed keep. The alignment batch is complete: the uniform +`*Request` rename (Q83), the feature-slice layout migration (Q84), and the Extensions +bring-up (Q85 — `AddOpenCode` overloads registering the root client and directly +resolvable sub-clients) have all landed; further breadth batches follow the PR #26 +review cycle. ## Milestones @@ -61,9 +62,9 @@ is revisited at each milestone boundary. 2. **M2 — Breadth batches.** The generation profile grows in vertical operation batches; each batch lands its curation rows, reachable models, operation methods, and contract tests together. The first batch (list/get/create/message-list) is complete with every - review rider resolved (#19–#22, #20, #25); the alignment batch (uniform `*Request`, - feature-slice layout, Extensions bring-up — research log Q83–Q85) precedes further - breadth. The Extensions package rises in parallel with these batches from here on. + review rider resolved (#19–#22, #20, #25), and so is the alignment batch (uniform + `*Request`, feature-slice layout, Extensions bring-up — research log Q83–Q85). The + Extensions package grows in parallel with the remaining batches. 3. **M3 — Streams.** SSE engine over the v2 stream surface (`v2.event.subscribe`, `v2.session.log` with `after`/`follow`, cursor-paged `v2.message.list`); the v1 durable-stream design does not carry over and is re-derived here. Demo: watching a diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index a341d0543..be308f190 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1817,3 +1817,17 @@ own batch; the ROADMAP moves "Extensions DI breadth" out of M6 accordingly. NSubstitute mechanism over internal seams and a recorded solved-once decision; internals are not a security boundary. The recorded alternative (hand-written fake replacing the grant) applies only if encapsulation purity is ever preferred. + +# Session 21 — 2026-08-14: Alignment batch execution + +The three sealed alignment decisions (Q83–Q85) executed on the PR #26 branch: the uniform +`*Request` rename with its body-plus-query double-derivation refusal pin, the feature-slice +layout migration with the writer's fail-closed family-folder allowlist, and the Extensions +bring-up. No sealed decision was reopened; one forward-looking note is recorded. + +## Note: generator-emitted DI registrations + +`AddOpenCode`'s sub-client registrations are hand-written — one factory line per client +family. Emitting these registrations from the generator becomes worthwhile when client +families multiply across breadth batches: the registration list is the same mechanical +projection the client emitters already own. This is a trigger, not a commitment. diff --git a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj index a1747db81..1086aaf91 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj +++ b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj @@ -7,8 +7,22 @@ true + + + + + + + + + + + + + + diff --git a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs new file mode 100644 index 000000000..c520e7f97 --- /dev/null +++ b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using OpenCode.Sdk; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Registers the opencode client family with a service collection: the root +/// as a singleton the container disposes, plus every +/// sub-client resolved from it so a consumer can inject +/// directly. +/// +public static class OpenCodeServiceCollectionExtensions +{ + /// Registers a client that owns its connection to the endpoint. + /// The service collection to register with. + /// The absolute HTTP or HTTPS server endpoint. + /// The same service collection. + public static IServiceCollection AddOpenCode(this IServiceCollection services, Uri endpoint) => + services.AddOpenCode(endpoint, configure: null); + + /// Registers a client that owns its connection to the endpoint. + /// The service collection to register with. + /// The absolute HTTP or HTTPS server endpoint. + /// Shapes the client options; the endpoint must stay unset on this path. + /// The same service collection. + public static IServiceCollection AddOpenCode(this IServiceCollection services, Uri endpoint, + Action? configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(endpoint); + + _ = services.AddSingleton(_ => new OpenCodeClient(endpoint, CreateOptions(configure))); + return AddSubClients(services); + } + + /// Registers a client over a caller-owned HttpClient; neither the SDK nor the container disposes it. + /// The service collection to register with. + /// The caller-owned HTTP client. + /// Shapes the client options; the endpoint is required on this path. + /// The same service collection. + public static IServiceCollection AddOpenCode(this IServiceCollection services, HttpClient httpClient, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(configure); + + _ = services.AddSingleton(_ => new OpenCodeClient(httpClient, CreateOptions(configure))); + return AddSubClients(services); + } + + private static OpenCodeClientOptions CreateOptions(Action? configure) + { + var options = new OpenCodeClientOptions(); + configure?.Invoke(options); + return options; + } + + private static IServiceCollection AddSubClients(IServiceCollection services) + { + _ = services.AddSingleton(static provider => provider.GetRequiredService().Sessions); + return services; + } +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/App.net472.config b/tests/OpenCode.Sdk.Extensions.Tests/App.net472.config new file mode 100644 index 000000000..1995750cc --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/App.net472.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json b/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json new file mode 100644 index 000000000..2bd7e2c45 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Fixtures/known-health.json @@ -0,0 +1 @@ +{"healthy":true,"version":"0.0.0-test","pid":100} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj new file mode 100644 index 000000000..5ca739319 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj @@ -0,0 +1,39 @@ + + + + Exe + net8.0;net9.0;$(DefaultTargetFramework) + false + + net472;$(TargetFrameworks) + + App.net472.config + + $(NoWarn);S1075 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..1f69309c5 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenCode.Sdk.Extensions.Tests.Support; + +namespace OpenCode.Sdk.Extensions.Tests; + +public sealed class OpenCodeServiceCollectionExtensionsTests +{ + private static readonly Uri Endpoint = new("http://localhost:4096"); + + [Test] + public async Task AddOpenCode_Should_Register_The_Root_Client_As_A_Singleton() + { + var services = new ServiceCollection().AddOpenCode(Endpoint); + using var provider = services.BuildServiceProvider(); + + var client = provider.GetRequiredService(); + + await Assert.That(provider.GetRequiredService()).IsSameReferenceAs(client); + } + + [Test] + public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_From_The_Root_Client() + { + var services = new ServiceCollection().AddOpenCode(Endpoint); + using var provider = services.BuildServiceProvider(); + + var sessions = provider.GetRequiredService(); + + await Assert.That(sessions).IsSameReferenceAs(provider.GetRequiredService().Sessions); + } + + [Test] + public async Task AddOpenCode_Should_Apply_The_Configure_Action_When_Building_The_Client() + { + var configured = new List(); + var services = new ServiceCollection().AddOpenCode(Endpoint, configured.Add); + using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredService(); + + await Assert.That(configured).HasSingleItem(); + } + + [Test] + public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_When_A_Configure_Action_Is_Supplied() + { + var services = new ServiceCollection().AddOpenCode(Endpoint, static options => options.Password = "secret"); + using var provider = services.BuildServiceProvider(); + + var sessions = provider.GetRequiredService(); + + await Assert.That(sessions).IsSameReferenceAs(provider.GetRequiredService().Sessions); + } + + [Test] + public async Task AddOpenCode_Should_Send_Through_The_Caller_Owned_Http_Client() + { + var payload = new FixtureLoader().LoadJson("known-health.json"); + using var handler = RecordingHttpHandler.RespondingJson(payload); + using var httpClient = new HttpClient(handler); + var services = new ServiceCollection().AddOpenCode(httpClient, options => options.Endpoint = Endpoint); + using var provider = services.BuildServiceProvider(); + + var response = await provider.GetRequiredService().GetHealthAsync(); + + await Assert.That(response.Health.Healthy).IsTrue(); + await Assert.That(handler.RequestUris.Single()).IsEqualTo(new Uri("http://localhost:4096/api/health")); + } + + [Test] + public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_When_The_Http_Client_Is_Caller_Owned() + { + using var handler = RecordingHttpHandler.RespondingJson("{}"); + using var httpClient = new HttpClient(handler); + var services = new ServiceCollection().AddOpenCode(httpClient, options => options.Endpoint = Endpoint); + using var provider = services.BuildServiceProvider(); + + var sessions = provider.GetRequiredService(); + + await Assert.That(sessions).IsSameReferenceAs(provider.GetRequiredService().Sessions); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Null_Service_Collection() + { + _ = await Assert.That(() => ((IServiceCollection)null!).AddOpenCode(Endpoint)) + .Throws(); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Null_Endpoint() + { + _ = await Assert.That(() => new ServiceCollection().AddOpenCode(endpoint: null!)) + .Throws(); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Null_Http_Client() + { + _ = await Assert.That(() => new ServiceCollection().AddOpenCode(httpClient: null!, static _ => { })) + .Throws(); + } + + [Test] + public async Task AddOpenCode_Should_Refuse_A_Null_Configure_Action_When_The_Http_Client_Is_Caller_Owned() + { + using var handler = RecordingHttpHandler.RespondingJson("{}"); + using var httpClient = new HttpClient(handler); + + _ = await Assert.That(() => new ServiceCollection().AddOpenCode(httpClient, configure: null!)) + .Throws(); + } +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs new file mode 100644 index 000000000..04d9bc47a --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/FixtureLoader.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using System.Text; + +namespace OpenCode.Sdk.Extensions.Tests.Support; + +internal sealed class FixtureLoader +{ + private const string ResourcePrefix = "OpenCode.Sdk.Extensions.Tests.Fixtures."; + private readonly Assembly _assembly = typeof(FixtureLoader).Assembly; + + public string LoadJson(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var resourceName = string.Concat(ResourcePrefix, name); + var stream = _assembly.GetManifestResourceStream(resourceName) + ?? throw new ArgumentException($"Embedded JSON fixture '{name}' was not found.", nameof(name)); + + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); + var content = Encoding.UTF8.GetString(reader.ReadBytes(checked((int)stream.Length))); + return content.TrimEnd('\r', '\n'); + } +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs new file mode 100644 index 000000000..03c59dad7 --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs @@ -0,0 +1,26 @@ +using System.Net; + +namespace OpenCode.Sdk.Extensions.Tests.Support; + +/// Records every request URI and answers from the supplied responder. +internal sealed class RecordingHttpHandler(Func responder) : HttpMessageHandler +{ + private readonly Func _responder = + responder ?? throw new ArgumentNullException(nameof(responder)); + + private readonly List _requestUris = []; + + public IReadOnlyList RequestUris => _requestUris; + + public static RecordingHttpHandler RespondingJson(string body) => + new(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body), + }); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _requestUris.Add(request.RequestUri!); + return Task.FromResult(_responder(request)); + } +} From e008f1d25634c4631d6c9f77f326b702d8ebd302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 16:33:01 +0300 Subject: [PATCH 23/47] build(submodule): move the opencode pin to the v2 spec commit The external/opencode gitlink still pointed at the 1.x line (v1.18.15) while spec/ has pinned v2-branch commit a6a712a since the M1 retarget - every fresh clone checked out a tree that no longer matches the surface the SDK is built against. The gitlink now records the exact spec-pin commit, so 'git submodule update --init' lands there mechanically; .gitmodules documents the v2 tracking branch, and the SNAPSHOT.md refresh procedure gains the lockstep step so the pointer and the snapshot never diverge again. --- .gitmodules | 1 + external/opencode | 2 +- spec/SNAPSHOT.md | 8 ++++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 1bd4ab1a6..1b98a1723 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,4 @@ path = external/opencode url = https://github.com/anomalyco/opencode shallow = true + branch = v2 diff --git a/external/opencode b/external/opencode index d7b115f62..a6a712a3a 160000 --- a/external/opencode +++ b/external/opencode @@ -1 +1 @@ -Subproject commit d7b115f623760e68a4749d16508a9eca350f246f +Subproject commit a6a712a3ac72248c9b2f2f883e752e6e18ef8c40 diff --git a/spec/SNAPSHOT.md b/spec/SNAPSHOT.md index cf497e939..6f6ecd820 100644 --- a/spec/SNAPSHOT.md +++ b/spec/SNAPSHOT.md @@ -21,8 +21,12 @@ Platform evidence for the v2 line: `docs/research/15-opencode-v2-platform.md`. that commit (read-only `git fetch` in the `external/opencode` submodule, or `raw.githubusercontent.com` pinned to the full commit SHA — never a branch ref). 2. Copy it over `spec/openapi.json`. -3. Update the table above (commit, channel) and the `Date:` line. -4. Run `generate`; resolve what it reports (wall admits, curation rows) and review the +3. Move the `external/opencode` submodule pointer to the same commit in the same change + (`git -C external/opencode fetch origin v2 && git -C external/opencode checkout + `, then stage the gitlink) — the submodule checkout and this snapshot never + diverge, so a fresh `git submodule update --init` always lands on the pinned commit. +4. Update the table above (commit, channel) and the `Date:` line. +5. Run `generate`; resolve what it reports (wall admits, curation rows) and review the regenerated diff. A dedicated spec-refresh tool is planned — see repo tooling in `docs/ROADMAP.md`. From 7ab8ae19700f0bf81a9a1a82487e5d2e7bbfe3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 16:58:07 +0300 Subject: [PATCH 24/47] chore(repo): stop ignoring the tracked submodule path .gitignore carried an external/ pattern that never affected the tracked gitlinks but made every 'git add external/opencode' refuse with an ignored-path warning - the submodule pin update tripped over it. The submodules are ordinary tracked entries; they need no ignore rule. --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 092bc6e35..158aad670 100644 --- a/.gitignore +++ b/.gitignore @@ -417,7 +417,6 @@ internal-docs/ !.claude/skills/ !.claude/skills/** -external/ tmp/ .fake From 451434e65e7f677c4ac2d1560f64aca95aaef7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 16:58:07 +0300 Subject: [PATCH 25/47] docs(research): capture the v2 location, auth, and census re-research Research doc 15 gains the parameter-placement census at the pin (5a: zero header params; the location deepObject on 61 of 120 ops; all 15 body+query mixes caused by location alone; session.list as the single flat-field exception) and the re-derived auth and location mechanics (optional Basic auth, configurable username via --username / OPENCODE_SERVER_USERNAME defaulting to opencode, client-side password resolution owned by the CLI consumer over OPENCODE_PASSWORD -> OPENCODE_SERVER_PASSWORD, dual-channel location resolution with query > header precedence). Research log Session 22 records the findings and the sealed decision: the location + merged-Request input design runs as a short session opening M3 planning instead of waiting for the collision wall. The vision spec's obsolete header-only Directory story is corrected in place; the ROADMAP M3 milestone carries the design session. --- docs/ROADMAP.md | 6 +- docs/research/00-research-log.md | 37 +++++++++++ docs/research/15-opencode-v2-platform.md | 64 +++++++++++++++---- .../specs/2026-08-09-public-api-design.md | 13 ++-- 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d1d9da24d..12d0d518a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,7 +65,11 @@ is revisited at each milestone boundary. review rider resolved (#19–#22, #20, #25), and so is the alignment batch (uniform `*Request`, feature-slice layout, Extensions bring-up — research log Q83–Q85). The Extensions package grows in parallel with the remaining batches. -3. **M3 — Streams.** SSE engine over the v2 stream surface (`v2.event.subscribe`, +3. **M3 — Streams.** Planning opens with the **location + merged-Request input design + session** (sealed 2026-08-14, research log Session 22): the dual-channel location + mechanism, deepObject marshalling, the one-`*Request`-carrying-body-and-query shape, + and the `session.list` flat-field exception — census and mechanisms in research doc 15 + §5a/§6. Then the SSE engine over the v2 stream surface (`v2.event.subscribe`, `v2.session.log` with `after`/`follow`, cursor-paged `v2.message.list`); the v1 durable-stream design does not carry over and is re-derived here. Demo: watching a real session's event stream. The net472 `ServicePointManager` item lands here. The diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index be308f190..404be4b14 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1831,3 +1831,40 @@ bring-up. No sealed decision was reopened; one forward-looking note is recorded. family. Emitting these registrations from the generator becomes worthwhile when client families multiply across breadth batches: the registration list is the same mechanical projection the client emitters already own. This is a trigger, not a commitment. + +# Session 22 — 2026-08-14: Extensions grill, upstream re-research, location census + +The maintainer grilled the Q85 first cut (constructor-mirror DI shape, BYO-HttpClient +overload, missing IHttpClientFactory, options mutability, env-fallback double-headedness) +and demanded upstream verification instead of vision-doc relay. Findings re-derived at the +pinned commit `a6a712a` directly; canonical detail in research doc 15 (§5a, §6). The +`external/opencode` submodule pointer was found stranded on the 1.x line and moved to the +spec pin with a lockstep rule in `spec/SNAPSHOT.md`. + +## Q87: Is `x-opencode-directory` dead on v2? + +**Found:** no — evolved. The server's location middleware resolves dual-channel: +`location[…]` deepObject query (61 ops, spec-visible, used exclusively by the first-party +generated client) OR ambient `x-opencode-directory`/`x-opencode-workspace` headers +(spec-invisible, middleware-level), precedence query > header. The vision spec's +header-only `Directory` story is obsolete; its §6 was corrected in place. + +## Q88: Can the merged-Request need be foreseen instead of wall-triggered? + +**Found:** yes — censusable today: 15 ops mix body + query, and in all 15 the only query +parameter is `location`; `v2.session.list` is the single flat-field exception. The merged +marshalling question and the location question are one design. + +**Decision (maintainer, sealed):** the location + merged-Request input design is done +proactively in a short design session that opens M3 planning — not deferred until the +collision wall fires. Census and mechanism notes live in doc 15 §5a/§6. + +## Q89: Where do v2 endpoint and credentials actually come from? + +**Found:** no URL environment variable exists; the first-party discovery contract is the +registration file (doc 15 §6). Auth is optional (`ServerAuth.required` = configured, +non-empty password). The username is configurable (`--username`/`OPENCODE_SERVER_USERNAME`, +default `opencode`) — our pipeline's hardcoded `opencode` user is incomplete. Client-side +password-from-environment resolution lives in the CLI consumer +(`OPENCODE_PASSWORD` → legacy `OPENCODE_SERVER_PASSWORD`), not in the client library. +Feeds the pending Q86 batch (options shape, env-fallback ownership, `Username` option). diff --git a/docs/research/15-opencode-v2-platform.md b/docs/research/15-opencode-v2-platform.md index 2f5e1f6aa..91c008f59 100644 --- a/docs/research/15-opencode-v2-platform.md +++ b/docs/research/15-opencode-v2-platform.md @@ -1,17 +1,19 @@ # The opencode v2 platform: branch, surface, architecture, distribution -Date: 2026-08-13 +Date: 2026-08-14 > Retarget-decision research (docs 09/10 follow-up). Question: what is the opencode v2 platform > today — which branch carries it, what API surface it serves, how its server/client architecture -> and its distribution work, and what retargeting this SDK onto it changes. Sources, all retrieved +> and its distribution work, and what retargeting this SDK onto it changes. Sources, retrieved > 2026-08-13: `anomalyco/opencode` branch `v2` at head `1288161` ("chore: generate"), inspected > via shallow clone; the GitHub REST API (branch head, commit list, releases, default branch); the > npm registry (`@opencode-ai/cli` and `opencode-ai` dist-tags); live probes of > `update.opencode.ai`; and a live install and run of `@opencode-ai/cli@next` (0.0.0-next-17403) -> on this machine. This document is the canonical current-state picture of the v2 platform; docs -> 09 and 10 remain the dated v1-line genealogy it extends, and research log session 12 (Q58) -> recorded the branch's first sighting. +> on this machine. §5a and the auth detail in §6 were re-derived 2026-08-14 against the pinned +> spec commit `a6a712a` directly (submodule checkout, `git grep`/`git show` at the pin). This +> document is the canonical current-state picture of the v2 platform; docs 09 and 10 remain the +> dated v1-line genealogy it extends, and research log session 12 (Q58) recorded the branch's +> first sighting. ## 1. The active branch is `v2`, and the investment is visibly there @@ -115,6 +117,27 @@ mangling rule covers them. Two-day drift against session 12's census of the same (2026-08-11: 322 schemas, 422 `allOf`, 359 single-value enums) measures the churn rate directly — the branch moves daily, which is exactly what snapshot pinning is for. +### 5a. Parameter-placement census at the pin (`a6a712a`, 2026-08-14) + +120 operations: 59 GET, 44 POST, 12 DELETE, 3 PUT, 2 PATCH. Placement facts that bound the +SDK's request-marshalling design: + +- **Header parameters: zero.** No operation declares an `in: header` parameter; the + ambient location headers (§6) are middleware-level and spec-invisible. +- **`location` deepObject: 61 operations** carry an optional `location` query parameter + (`style: deepObject`, `explode: true` — wire shape `location[directory]=…&location[workspace]=…`, + schema `anyOf[{directory?, workspace?}, null]`). +- **Body + query mixing: 15 operations** (POST/PUT/PATCH/DELETE) — and in every one of them + the *only* query parameter is `location`. The merged-Request marshalling question and the + location question are the same design. +- **Flat location fields: exactly one operation** — `v2.session.list` carries + `directory`/`workspace`/`project`/`subpath` as plain query parameters instead of the + deepObject; the platform-wide mechanism is the deepObject. + +Side observation validating the Q83 rename: the v2 first-party generated client emits one +uniform `{Op}Input` type per operation (`SessionListInput`, `SessionPromptInput`, …) — +upstream's own rendering of the uniform-request idea. + **Streaming reshape.** `text/event-stream` lives on `v2.event.subscribe` and `v2.session.log`; `v2.pty.connect` carries `x-websocket`; `x-effect-stream` rides both SSE operations (2026-08-13 ingestion-level correction of this session's earlier jq census). The v1 durable @@ -162,10 +185,27 @@ dies, server exits. It scrubs `OPENCODE_PASSWORD`/`OPENCODE_SERVER_PASSWORD` fro environment and generates a random password. Made for embedding hosts (the desktop uses the bundled CLI this way). -**Auth is unchanged from v1**: HTTP Basic, username `opencode`, optional password; service mode -generates `randomBytes(32).toString("base64url")` when unconfigured; foreground `serve` prints -the password when it generated one. The SDK's decoration decisions carry over verbatim; the -`location[…]`-vs-`x-opencode-directory` addressing question is a retarget-time decision. +**Auth (re-derived at the pin, 2026-08-14)**: HTTP Basic, and **optional** — the server's +`ServerAuth.required` is true only when a password is configured and non-empty; otherwise the +authorization middleware is a pass-through (`packages/server/src/middleware/authorization.ts`). +The username is **configurable**: default `opencode`, overridden by `--username`/`-u` on +`serve`/`web` or `OPENCODE_SERVER_USERNAME` (upstream CLI/server docs; the desktop WSL sidecar +exports it). Client-side password resolution lives in the *consumer*, not the client library: +`packages/cli/src/env.ts` resolves `OPENCODE_PASSWORD` falling back to the legacy +`OPENCODE_SERVER_PASSWORD` and hands the value to the client — the generated client itself +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 +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). ## 7. MCP is client-side only; the exposure protocol they chose is ACP @@ -245,8 +285,10 @@ lifecycle-leash bonus; connect-or-launch fits the discovery-file model). **Changes at retarget:** the ingestion wall needs one new admit rule (single-element, validation-only `allOf` unwrap); `HealthResponse` maps `ServiceHealth`; the -`location[…]`-query vs `x-opencode-directory`-header decoration policy must be decided; the M3 -durable-stream design is re-derived against `session.log` (`after` + `follow`). +`location[…]`-query vs ambient-header decoration policy is settled as a design input (§6: +dual-channel, query > header) and its SDK rendering is designed in the location + +merged-Request session at M3 planning; the M3 durable-stream design is re-derived against +`session.log` (`after` + `follow`). ## UNVERIFIED / open diff --git a/docs/superpowers/specs/2026-08-09-public-api-design.md b/docs/superpowers/specs/2026-08-09-public-api-design.md index 044f83718..698ae611a 100644 --- a/docs/superpowers/specs/2026-08-09-public-api-design.md +++ b/docs/superpowers/specs/2026-08-09-public-api-design.md @@ -296,12 +296,13 @@ public sealed class OpenCodeRequestOptions demonstrates the need. - `CancellationToken` stays a **separate last parameter** (TAP convention — deliberately unlike Azure's RequestContext, which folds it in). -- `Directory` resolves the ROADMAP question: client-level default (§10) + per-call - override, the .NET rendering of upstream's `createOpencodeClient({directory})` + - per-request header. Carried as the `x-opencode-directory` header on every request, - SSE included (header-only; upstream's GET/HEAD query rewrite is a browser/EventSource - constraint we don't share). Server precedence is query > header, so explicit per-op - `location[…]` parameters override the ambient value — documented behavior. +- `Directory` as a concept — client-level ambient default (§10) + per-call override — + survives, but the v1 header-only transport described here is obsolete: v2 resolves + location dual-channel (`location[…]` deepObject query parameters, used exclusively by + the first-party client, OR ambient `x-opencode-directory`/`x-opencode-workspace` + headers; precedence query > header — research doc 15 §6). The concrete SDK rendering + is designed in the location + merged-Request session at M3 planning (research log + Session 22). - The options class grows extend-only (adding properties is non-breaking). ## 7. Client composition From a0c83a9a30566674e156bcbae1ca5bfab607e195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 17:52:50 +0300 Subject: [PATCH 26/47] feat(sdk): reshape construction, options, and DI onto .NET conventions Sealed as the in-session 'Q86' batch (research log Q90). The client now constructs from options alone - OpenCodeClient(OpenCodeClientOptions) and the caller-owned-HttpClient overload; the Uri constructors retire and the endpoint has exactly one home, killing the endpoint-must-stay-unset guard. Options gain the read-only IOpenCodeClientOptions view the pipeline consumes and snapshots at construction (mutating an options object after a client is built never changes that client), plus a configurable Username (upstream: --username / OPENCODE_SERVER_USERNAME, default opencode) beside the optional Password whose null now plainly means anonymous requests - auth is optional on v2 servers. The OPENCODE_SERVER_PASSWORD environment fallback leaves the SDK entirely (upstream layering: the consumer resolves the environment; revises the env half of #20 on Session 22 evidence), and the environment-provider seam retires with it. The self-created transport rides SocketsHttpHandler with a pooled connection lifetime on modern TFMs. Extensions rebuilds on IHttpClientFactory: AddOpenCode(Action<...>) and a config-binding AddOpenCode(IConfiguration) (AOT-annotated) register a transient typed client over a named factory client and return the IHttpClientBuilder, so consumer handlers, resilience, and telemetry chain on without SDK middleware; SessionsClient resolves straight from the container. The sandbox becomes the live showcase: Generic Host, AddOpenCode, a consumer-owned ConsoleTimingHandler on the builder, and direct sub-client resolution. The PublicApi baseline diff carries exactly this reshape. --- Directory.Packages.props | 4 + .../OpenCode.Sdk.Extensions.csproj | 2 + .../OpenCodeServiceCollectionExtensions.cs | 73 ++++++------ src/OpenCode.Sdk/IOpenCodeClientOptions.cs | 18 +++ .../Abstractions/IEnvironmentProvider.cs | 10 -- src/OpenCode.Sdk/Internal/Pipeline.cs | 88 +++++++++----- .../Internal/SystemEnvironmentProvider.cs | 14 --- src/OpenCode.Sdk/OpenCodeClient.cs | 19 +--- src/OpenCode.Sdk/OpenCodeClientOptions.cs | 28 +++-- .../OpenCode.Sdk.Extensions.Tests.csproj | 1 + ...penCodeServiceCollectionExtensionsTests.cs | 107 ++++++++++-------- .../Support/OpenCodeConfigurationData.cs | 14 +++ .../Support/RecordingHttpHandler.cs | 6 +- .../Support/WitnessHandler.cs | 13 +++ .../ConsoleTimingHandler.cs | 22 ++++ .../OpenCode.Sdk.Sandbox.csproj | 5 + tests/OpenCode.Sdk.Sandbox/Program.cs | 35 +++++- tests/OpenCode.Sdk.Sandbox/README.md | 19 ++-- .../OpenCodeClientContractTests.cs | 8 +- .../OpenCodeClientOptionsTests.cs | 14 +++ tests/OpenCode.Sdk.Tests/PipelineTests.cs | 105 +++++++++++------ .../Snapshots/PublicApi.verified.txt | 12 +- .../SystemEnvironmentProviderTests.cs | 37 ------ ...ce_Virtual_Delegating_Clients.verified.txt | 19 +--- .../Generator/Binding/OperationPlanBinder.cs | 1 + .../Generator/Emission/ClientEmitter.cs | 47 +++----- 26 files changed, 422 insertions(+), 299 deletions(-) create mode 100644 src/OpenCode.Sdk/IOpenCodeClientOptions.cs delete mode 100644 src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs delete mode 100644 src/OpenCode.Sdk/Internal/SystemEnvironmentProvider.cs create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/Support/OpenCodeConfigurationData.cs create mode 100644 tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs create mode 100644 tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs create mode 100644 tests/OpenCode.Sdk.Tests/OpenCodeClientOptionsTests.cs delete mode 100644 tests/OpenCode.Sdk.Tests/SystemEnvironmentProviderTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 86a423c61..d7e0ac8e8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,8 +15,12 @@ + + + + diff --git a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj index 1086aaf91..272f450fd 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj +++ b/src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs index c520e7f97..c74dd3f01 100644 --- a/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs +++ b/src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs @@ -1,63 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using OpenCode.Sdk; namespace Microsoft.Extensions.DependencyInjection; /// -/// Registers the opencode client family with a service collection: the root -/// as a singleton the container disposes, plus every -/// sub-client resolved from it so a consumer can inject -/// directly. +/// Registers the opencode client family over IHttpClientFactory: a typed +/// riding factory-managed transport, plus every sub-client +/// resolved from it so a consumer can inject directly. The +/// returned is the composition seam — chain +/// AddHttpMessageHandler, resilience handlers, or telemetry instrumentation onto it. /// public static class OpenCodeServiceCollectionExtensions { - /// Registers a client that owns its connection to the endpoint. + /// Registers the opencode client, shaping its options in code. /// The service collection to register with. - /// The absolute HTTP or HTTPS server endpoint. - /// The same service collection. - public static IServiceCollection AddOpenCode(this IServiceCollection services, Uri endpoint) => - services.AddOpenCode(endpoint, configure: null); - - /// Registers a client that owns its connection to the endpoint. - /// The service collection to register with. - /// The absolute HTTP or HTTPS server endpoint. - /// Shapes the client options; the endpoint must stay unset on this path. - /// The same service collection. - public static IServiceCollection AddOpenCode(this IServiceCollection services, Uri endpoint, - Action? configure) + /// Shapes the client options; the endpoint is required. + /// The typed-client builder for transport composition. + public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, Action configure) { ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(configure); - _ = services.AddSingleton(_ => new OpenCodeClient(endpoint, CreateOptions(configure))); - return AddSubClients(services); + _ = services.Configure(configure); + return AddOpenCodeCore(services); } - /// Registers a client over a caller-owned HttpClient; neither the SDK nor the container disposes it. + /// Registers the opencode client, binding its options from configuration. /// The service collection to register with. - /// The caller-owned HTTP client. - /// Shapes the client options; the endpoint is required on this path. - /// The same service collection. - public static IServiceCollection AddOpenCode(this IServiceCollection services, HttpClient httpClient, - Action configure) + /// The configuration section carrying the client options. + /// The typed-client builder for transport composition. + [RequiresDynamicCode("Configuration binding uses reflection over the options type; prefer the configure-action overload on native AOT.")] + [RequiresUnreferencedCode("Configuration binding may require members trimming removes; prefer the configure-action overload when trimming.")] + public static IHttpClientBuilder AddOpenCode(this IServiceCollection services, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(configure); + ArgumentNullException.ThrowIfNull(configuration); - _ = services.AddSingleton(_ => new OpenCodeClient(httpClient, CreateOptions(configure))); - return AddSubClients(services); - } - - private static OpenCodeClientOptions CreateOptions(Action? configure) - { - var options = new OpenCodeClientOptions(); - configure?.Invoke(options); - return options; + _ = services.AddOptions().Bind(configuration); + return AddOpenCodeCore(services); } - private static IServiceCollection AddSubClients(IServiceCollection services) + private static IHttpClientBuilder AddOpenCodeCore(IServiceCollection services) { - _ = services.AddSingleton(static provider => provider.GetRequiredService().Sessions); - return services; + // A named factory client bound as the typed client: the factory owns the HttpClient + // and its handler rotation, so the client registers transient and disposes nothing. + var builder = services.AddHttpClient(nameof(OpenCodeClient)) + .AddTypedClient(static (httpClient, provider) => new OpenCodeClient( + httpClient, + provider.GetRequiredService>().Value)); + _ = services.AddTransient(static SessionsClient (provider) => provider.GetRequiredService().Sessions); + return builder; } } diff --git a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs new file mode 100644 index 000000000..ca95de596 --- /dev/null +++ b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs @@ -0,0 +1,18 @@ +namespace OpenCode.Sdk; + +/// +/// The read-only view of the client options. Consumption sites — the pipeline, the DI +/// composition — read through this contract and snapshot at construction, so an options +/// instance mutated after a client is built never changes that client's behavior. +/// +public interface IOpenCodeClientOptions +{ + /// Gets the absolute HTTP or HTTPS server endpoint; required to build a client. + public Uri? Endpoint { get; } + + /// Gets the HTTP basic-authentication username; the upstream default is opencode. + public string Username { get; } + + /// Gets the HTTP basic-authentication password; sends anonymous requests. + public string? Password { get; } +} diff --git a/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs b/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs deleted file mode 100644 index 8996eccd2..000000000 --- a/src/OpenCode.Sdk/Internal/Abstractions/IEnvironmentProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace OpenCode.Sdk.Internal.Abstractions; - -/// Reads process environment variables behind a substitutable seam. -internal interface IEnvironmentProvider -{ - /// Reads one environment variable. - /// The variable name. - /// The value, or when the variable is not set. - public string? GetEnvironmentVariable(string name); -} diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 214e2a349..6829d53d5 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -3,16 +3,12 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; -using OpenCode.Sdk.Internal.Abstractions; namespace OpenCode.Sdk.Internal; /// Owns request decoration, sending, buffering, and error-channel policy for every operation. internal sealed class Pipeline : IDisposable { - private const string BasicUser = "opencode"; - private const string PasswordVariable = "OPENCODE_SERVER_PASSWORD"; - private readonly AuthenticationHeaderValue? _authorization; private readonly string _endpointBase; private readonly HttpClient _httpClient; @@ -20,20 +16,35 @@ internal sealed class Pipeline : IDisposable private readonly ProductInfoHeaderValue _userAgent; private bool _disposed; - internal Pipeline(HttpClient httpClient, bool ownsHttpClient, Uri endpoint, string? password, IEnvironmentProvider environment) + internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOptions options) { ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(endpoint); - ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(options); + var endpoint = options.Endpoint + ?? throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options)); + + // Basic credentials ride the wire as "username:password", so a blank name or a colon + // inside it would corrupt the header instead of failing a login. + var username = options.Username; + if (string.IsNullOrWhiteSpace(username)) + { + throw new ArgumentException("The username cannot be empty or whitespace.", nameof(options)); + } - // An explicitly blank password has no upstream meaning (the server always generates - // one), so it fails loudly instead of inventing an empty-credential mode; null stays - // the unset spelling that reaches the environment fallback below. + if (username.Contains(':', StringComparison.Ordinal)) + { + throw new ArgumentException("The username cannot contain a colon.", nameof(options)); + } + + // An explicitly blank password has no upstream meaning (a server without configured + // authentication expects no credentials at all), so it fails loudly; null is the + // anonymous spelling. + var password = options.Password; if (password is not null && string.IsNullOrWhiteSpace(password)) { throw new ArgumentException( - "An explicit password cannot be empty or whitespace; leave it unset to use the OPENCODE_SERVER_PASSWORD fallback.", - nameof(password)); + "An explicit password cannot be empty or whitespace; leave it null for a server without authentication.", + nameof(options)); } _httpClient = httpClient; @@ -41,36 +52,32 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, Uri endpoint, stri _endpointBase = EndpointPolicy.Normalize(endpoint); _userAgent = UserAgentPolicy.Resolve(); - // The environment fallback is read exactly once, here; requests reuse the resolved header. - var resolvedPassword = string.IsNullOrEmpty(password) ? environment.GetEnvironmentVariable(PasswordVariable) : password; - _authorization = string.IsNullOrEmpty(resolvedPassword) + // The options are read exactly once, here: the pipeline holds an immutable snapshot, + // so mutating the options object after construction never changes a built client. + _authorization = password is null ? null : new AuthenticationHeaderValue( "Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes($"{BasicUser}:{resolvedPassword}"))); + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); } - public static Pipeline Create(Uri endpoint, OpenCodeClientOptions? options, IEnvironmentProvider environment) + public static Pipeline Create(OpenCodeClientOptions options) { - ArgumentNullException.ThrowIfNull(endpoint); - ArgumentNullException.ThrowIfNull(environment); - if (options?.Endpoint is not null) + ArgumentNullException.ThrowIfNull(options); + if (options.Endpoint is null) { - throw new ArgumentException( - "The endpoint constructor owns endpoint authority; leave OpenCodeClientOptions.Endpoint unset.", - nameof(options)); + throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options)); } // Validate before constructing the owned client so a refused endpoint leaks nothing. - _ = EndpointPolicy.Normalize(endpoint); - return new Pipeline(new HttpClient(), ownsHttpClient: true, endpoint, options?.Password, environment); + _ = EndpointPolicy.Normalize(options.Endpoint); + return new Pipeline(CreateOwnedHttpClient(), ownsHttpClient: true, options); } - public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions options, IEnvironmentProvider environment) + public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions options) { ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(environment); if (options.Endpoint is null) { throw new ArgumentException( @@ -78,7 +85,32 @@ public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions optio nameof(options)); } - return new Pipeline(httpClient, ownsHttpClient: false, options.Endpoint, options.Password, environment); + return new Pipeline(httpClient, ownsHttpClient: false, options); + } + + /// The owned transport: pooled connection lifetime keeps DNS rotation alive on modern TFMs. + private static HttpClient CreateOwnedHttpClient() + { +#if NET + SocketsHttpHandler? handler = null; + try + { + handler = new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + var httpClient = new HttpClient(handler, disposeHandler: true); + handler = null; + return httpClient; + } + finally + { + handler?.Dispose(); + } +#else + // net472/netstandard2.0 stay on the default handler; ServicePointManager hardening is an M3 item. + return new HttpClient(); +#endif } public Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter, diff --git a/src/OpenCode.Sdk/Internal/SystemEnvironmentProvider.cs b/src/OpenCode.Sdk/Internal/SystemEnvironmentProvider.cs deleted file mode 100644 index c74b5b1cb..000000000 --- a/src/OpenCode.Sdk/Internal/SystemEnvironmentProvider.cs +++ /dev/null @@ -1,14 +0,0 @@ -using OpenCode.Sdk.Internal.Abstractions; - -namespace OpenCode.Sdk.Internal; - -/// Reads environment variables from the current process. -internal sealed class SystemEnvironmentProvider : IEnvironmentProvider -{ - public string? GetEnvironmentVariable(string name) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - - return Environment.GetEnvironmentVariable(name); - } -} diff --git a/src/OpenCode.Sdk/OpenCodeClient.cs b/src/OpenCode.Sdk/OpenCodeClient.cs index d5534e764..65b42fbc1 100644 --- a/src/OpenCode.Sdk/OpenCodeClient.cs +++ b/src/OpenCode.Sdk/OpenCodeClient.cs @@ -14,19 +14,10 @@ public class OpenCodeClient : IDisposable /// /// Initializes a client that owns its connection to the endpoint. /// - /// The absolute HTTP or HTTPS server endpoint. - public OpenCodeClient(Uri endpoint) : this(endpoint, options: null) + /// The client options; the endpoint is required. + public OpenCodeClient(OpenCodeClientOptions options) { - } - - /// - /// Initializes a client that owns its connection to the endpoint. - /// - /// The absolute HTTP or HTTPS server endpoint. - /// The client options; its endpoint must stay unset on this path. - public OpenCodeClient(Uri endpoint, OpenCodeClientOptions? options) - { - _pipeline = Pipeline.Create(endpoint, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(options); _sessions = new SessionsClient(_pipeline); } @@ -34,10 +25,10 @@ public OpenCodeClient(Uri endpoint, OpenCodeClientOptions? options) /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. /// /// The caller-owned HTTP client. - /// The client options; the endpoint is required on this path. + /// The client options; the endpoint is required. public OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { - _pipeline = Pipeline.Create(httpClient, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(httpClient, options); _sessions = new SessionsClient(_pipeline); } diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index 99d0df28e..4b7bbe55b 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -1,21 +1,27 @@ namespace OpenCode.Sdk; -/// Configures an opencode client for its whole lifetime. -public sealed class OpenCodeClientOptions +/// +/// Configures an opencode client for its whole lifetime. The class stays settable for the +/// options and configuration-binding patterns; clients snapshot it at construction through +/// , so later mutation never reaches a built client. +/// +public sealed class OpenCodeClientOptions : IOpenCodeClientOptions { + /// Gets or sets the absolute HTTP or HTTPS server endpoint; required to build a client. + public Uri? Endpoint { get; set; } + /// - /// Gets or sets the absolute HTTP or HTTPS server endpoint. Required when a caller - /// supplies its own HttpClient; must stay unset when a constructor receives - /// the endpoint directly. + /// Gets or sets the HTTP basic-authentication username. The upstream default is + /// opencode; servers started with --username or + /// OPENCODE_SERVER_USERNAME expect the matching value here. /// - public Uri? Endpoint { get; set; } + public string Username { get; set; } = "opencode"; /// - /// Gets or sets the HTTP basic-authentication password for the opencode user. - /// When unset (), the OPENCODE_SERVER_PASSWORD environment - /// variable is read once at client construction; an empty or whitespace value is refused - /// with — the server always generates a password, so an - /// explicitly blank one has no meaning. + /// Gets or sets the HTTP basic-authentication password. sends + /// anonymous requests — a server without authentication configured expects none. An + /// empty or whitespace value is refused with at client + /// construction; the SDK never reads credentials from the environment. /// public string? Password { get; set; } } diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj index 5ca739319..e4382d150 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCode.Sdk.Extensions.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs index 1f69309c5..bae604b26 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Text; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenCode.Sdk.Extensions.Tests.Support; @@ -8,106 +10,119 @@ public sealed class OpenCodeServiceCollectionExtensionsTests private static readonly Uri Endpoint = new("http://localhost:4096"); [Test] - public async Task AddOpenCode_Should_Register_The_Root_Client_As_A_Singleton() + public async Task AddOpenCode_Should_Resolve_The_Client_Through_The_Factory_Pipeline() { - var services = new ServiceCollection().AddOpenCode(Endpoint); + var payload = new FixtureLoader().LoadJson("known-health.json"); + using var handler = RecordingHttpHandler.RespondingJson(payload); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigurePrimaryHttpMessageHandler(() => handler); using var provider = services.BuildServiceProvider(); - var client = provider.GetRequiredService(); + var response = await provider.GetRequiredService().GetHealthAsync(); - await Assert.That(provider.GetRequiredService()).IsSameReferenceAs(client); + await Assert.That(response.Health.Healthy).IsTrue(); + await Assert.That(handler.RequestUris.Single()).IsEqualTo(new Uri("http://localhost:4096/api/health")); } [Test] - public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_From_The_Root_Client() + public async Task AddOpenCode_Should_Register_The_Client_As_Transient_For_Handler_Rotation() { - var services = new ServiceCollection().AddOpenCode(Endpoint); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint); using var provider = services.BuildServiceProvider(); - var sessions = provider.GetRequiredService(); + var first = provider.GetRequiredService(); + var second = provider.GetRequiredService(); - await Assert.That(sessions).IsSameReferenceAs(provider.GetRequiredService().Sessions); + await Assert.That(first).IsNotSameReferenceAs(second); } [Test] - public async Task AddOpenCode_Should_Apply_The_Configure_Action_When_Building_The_Client() + public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_Directly() { - var configured = new List(); - var services = new ServiceCollection().AddOpenCode(Endpoint, configured.Add); + var payload = new FixtureLoader().LoadJson("known-health.json"); + using var handler = RecordingHttpHandler.RespondingJson(payload); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigurePrimaryHttpMessageHandler(() => handler); using var provider = services.BuildServiceProvider(); - _ = provider.GetRequiredService(); + var sessions = provider.GetRequiredService(); - await Assert.That(configured).HasSingleItem(); + await Assert.That(sessions).IsNotNull(); } [Test] - public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_When_A_Configure_Action_Is_Supplied() + public async Task AddOpenCode_Should_Apply_The_Configure_Action_When_Building_The_Client() { - var services = new ServiceCollection().AddOpenCode(Endpoint, static options => options.Password = "secret"); + var configured = new List(); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => + { + options.Endpoint = Endpoint; + configured.Add(options); + }); using var provider = services.BuildServiceProvider(); - var sessions = provider.GetRequiredService(); + _ = provider.GetRequiredService(); - await Assert.That(sessions).IsSameReferenceAs(provider.GetRequiredService().Sessions); + await Assert.That(configured).HasSingleItem(); } [Test] - public async Task AddOpenCode_Should_Send_Through_The_Caller_Owned_Http_Client() + public async Task AddOpenCode_Should_Bind_The_Configuration_Section_Including_Credentials() { - var payload = new FixtureLoader().LoadJson("known-health.json"); - using var handler = RecordingHttpHandler.RespondingJson(payload); - using var httpClient = new HttpClient(handler); - var services = new ServiceCollection().AddOpenCode(httpClient, options => options.Endpoint = Endpoint); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(OpenCodeConfigurationData.ProtectedServer) + .Build(); + using var handler = RecordingHttpHandler.RespondingJson(new FixtureLoader().LoadJson("known-health.json")); + var services = new ServiceCollection(); + _ = services.AddOpenCode(configuration.GetSection("OpenCode")) + .ConfigurePrimaryHttpMessageHandler(() => handler); using var provider = services.BuildServiceProvider(); - var response = await provider.GetRequiredService().GetHealthAsync(); + _ = await provider.GetRequiredService().GetHealthAsync(); - await Assert.That(response.Health.Healthy).IsTrue(); - await Assert.That(handler.RequestUris.Single()).IsEqualTo(new Uri("http://localhost:4096/api/health")); + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secret"))}"; + await Assert.That(handler.AuthorizationHeaders.Single()).IsEqualTo(expected); } [Test] - public async Task AddOpenCode_Should_Resolve_The_Sessions_Client_When_The_Http_Client_Is_Caller_Owned() + public async Task AddOpenCode_Should_Compose_Delegating_Handlers_Through_The_Returned_Builder() { - using var handler = RecordingHttpHandler.RespondingJson("{}"); - using var httpClient = new HttpClient(handler); - var services = new ServiceCollection().AddOpenCode(httpClient, options => options.Endpoint = Endpoint); + var payload = new FixtureLoader().LoadJson("known-health.json"); + using var handler = RecordingHttpHandler.RespondingJson(payload); + using var witness = new WitnessHandler(); + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .AddHttpMessageHandler(() => witness) + .ConfigurePrimaryHttpMessageHandler(() => handler); using var provider = services.BuildServiceProvider(); - var sessions = provider.GetRequiredService(); + _ = await provider.GetRequiredService().GetHealthAsync(); - await Assert.That(sessions).IsSameReferenceAs(provider.GetRequiredService().Sessions); + await Assert.That(witness.CallCount).IsEqualTo(1); } [Test] public async Task AddOpenCode_Should_Refuse_A_Null_Service_Collection() { - _ = await Assert.That(() => ((IServiceCollection)null!).AddOpenCode(Endpoint)) - .Throws(); - } - - [Test] - public async Task AddOpenCode_Should_Refuse_A_Null_Endpoint() - { - _ = await Assert.That(() => new ServiceCollection().AddOpenCode(endpoint: null!)) + _ = await Assert.That(() => ((IServiceCollection)null!).AddOpenCode(static _ => { })) .Throws(); } [Test] - public async Task AddOpenCode_Should_Refuse_A_Null_Http_Client() + public async Task AddOpenCode_Should_Refuse_A_Null_Configure_Action() { - _ = await Assert.That(() => new ServiceCollection().AddOpenCode(httpClient: null!, static _ => { })) + _ = await Assert.That(() => new ServiceCollection().AddOpenCode(configure: null!)) .Throws(); } [Test] - public async Task AddOpenCode_Should_Refuse_A_Null_Configure_Action_When_The_Http_Client_Is_Caller_Owned() + public async Task AddOpenCode_Should_Refuse_A_Null_Configuration() { - using var handler = RecordingHttpHandler.RespondingJson("{}"); - using var httpClient = new HttpClient(handler); - - _ = await Assert.That(() => new ServiceCollection().AddOpenCode(httpClient, configure: null!)) + _ = await Assert.That(() => new ServiceCollection().AddOpenCode(configuration: null!)) .Throws(); } } diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/OpenCodeConfigurationData.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/OpenCodeConfigurationData.cs new file mode 100644 index 000000000..df165c02e --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/OpenCodeConfigurationData.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Extensions.Tests.Support; + +/// Canned configuration sections for the binding tests. +internal static class OpenCodeConfigurationData +{ + /// A password-protected server with a non-default username. + public static IReadOnlyDictionary ProtectedServer { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OpenCode:Endpoint"] = "http://localhost:4096", + ["OpenCode:Username"] = "admin", + ["OpenCode:Password"] = "secret", + }; +} diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs index 03c59dad7..b36aafc0a 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/RecordingHttpHandler.cs @@ -2,16 +2,19 @@ namespace OpenCode.Sdk.Extensions.Tests.Support; -/// Records every request URI and answers from the supplied responder. +/// Records every request URI and Authorization header, answering from the supplied responder. internal sealed class RecordingHttpHandler(Func responder) : HttpMessageHandler { private readonly Func _responder = responder ?? throw new ArgumentNullException(nameof(responder)); private readonly List _requestUris = []; + private readonly List _authorizationHeaders = []; public IReadOnlyList RequestUris => _requestUris; + public IReadOnlyList AuthorizationHeaders => _authorizationHeaders; + public static RecordingHttpHandler RespondingJson(string body) => new(_ => new HttpResponseMessage(HttpStatusCode.OK) { @@ -21,6 +24,7 @@ public static RecordingHttpHandler RespondingJson(string body) => protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { _requestUris.Add(request.RequestUri!); + _authorizationHeaders.Add(request.Headers.Authorization?.ToString()); return Task.FromResult(_responder(request)); } } diff --git a/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs b/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs new file mode 100644 index 000000000..bab1d382e --- /dev/null +++ b/tests/OpenCode.Sdk.Extensions.Tests/Support/WitnessHandler.cs @@ -0,0 +1,13 @@ +namespace OpenCode.Sdk.Extensions.Tests.Support; + +/// Counts the requests flowing through a composed delegating-handler chain. +internal sealed class WitnessHandler : DelegatingHandler +{ + public int CallCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return base.SendAsync(request, cancellationToken); + } +} diff --git a/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs b/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs new file mode 100644 index 000000000..27df1015f --- /dev/null +++ b/tests/OpenCode.Sdk.Sandbox/ConsoleTimingHandler.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.Globalization; + +namespace OpenCode.Sdk.Sandbox; + +/// +/// A consumer-owned delegating handler riding the factory chain — the third extensibility +/// rung in action: the SDK ships no middleware, the ecosystem seam carries it. +/// +internal sealed class ConsoleTimingHandler : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + Console.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $" [http] {request.Method} {request.RequestUri?.PathAndQuery} -> {(int)response.StatusCode} ({stopwatch.ElapsedMilliseconds} ms)")); + return response; + } +} diff --git a/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj b/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj index b03344a17..d8085b90b 100644 --- a/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj +++ b/tests/OpenCode.Sdk.Sandbox/OpenCode.Sdk.Sandbox.csproj @@ -7,6 +7,11 @@ + + + + + diff --git a/tests/OpenCode.Sdk.Sandbox/Program.cs b/tests/OpenCode.Sdk.Sandbox/Program.cs index bfbcd52be..de27e45e8 100644 --- a/tests/OpenCode.Sdk.Sandbox/Program.cs +++ b/tests/OpenCode.Sdk.Sandbox/Program.cs @@ -1,6 +1,9 @@ using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using OpenCode.Sdk; using OpenCode.Sdk.Models; +using OpenCode.Sdk.Sandbox; var endpoint = Environment.GetEnvironmentVariable("OPENCODE_SANDBOX_ENDPOINT"); if (string.IsNullOrWhiteSpace(endpoint)) @@ -8,19 +11,39 @@ await Console.Error.WriteLineAsync( "Set OPENCODE_SANDBOX_ENDPOINT to an absolute server endpoint; the launchSettings.json profile prefills it.").ConfigureAwait(false); await Console.Error.WriteLineAsync( - "Optional: OPENCODE_SERVER_PASSWORD (read by the SDK itself).").ConfigureAwait(false); + "Optional: OPENCODE_PASSWORD or OPENCODE_SERVER_PASSWORD (resolved here; the SDK reads no environment).").ConfigureAwait(false); return 1; } -// No explicit password: the SDK resolves OPENCODE_SERVER_PASSWORD on its own at construction. -using var client = new OpenCodeClient(new Uri(endpoint)); +// The consumer owns environment resolution (upstream's own layering; the CLI does the same): +// the SDK itself never reads environment variables. +var password = Environment.GetEnvironmentVariable("OPENCODE_PASSWORD") + ?? Environment.GetEnvironmentVariable("OPENCODE_SERVER_PASSWORD"); + +// The DI composition the Extensions package exists for: AddOpenCode registers the typed +// client over IHttpClientFactory and returns the IHttpClientBuilder, so consumer-owned +// delegating handlers (and resilience/telemetry packages) chain on without SDK middleware. +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddTransient(); +_ = builder.Services.AddOpenCode(options => + { + options.Endpoint = new Uri(endpoint); + options.Password = string.IsNullOrWhiteSpace(password) ? null : password; + }) + .AddHttpMessageHandler(); +using var host = builder.Build(); + +var client = host.Services.GetRequiredService(); var health = await client.GetHealthAsync().ConfigureAwait(false); Console.WriteLine(string.Create( CultureInfo.InvariantCulture, $"health: status={health.Status} healthy={health.Health.Healthy} version={health.Health.Version} pid={health.Health.Pid}")); -var created = await client.Sessions.CreateSessionAsync(new SessionCreateRequest +// Sub-clients resolve directly from the container as well. +var sessionsClient = host.Services.GetRequiredService(); + +var created = await sessionsClient.CreateSessionAsync(new SessionCreateRequest { Title = "sdk breadth demo", }).ConfigureAwait(false); @@ -28,7 +51,7 @@ await Console.Error.WriteLineAsync( CultureInfo.InvariantCulture, $"create: status={created.Status} id={created.Session.Id} title={created.Session.Title}")); -var page = await client.Sessions.ListSessionsAsync(new SessionListRequest +var page = await sessionsClient.ListSessionsAsync(new SessionListRequest { Limit = 3, Order = ListOrder.Descending, @@ -41,7 +64,7 @@ await Console.Error.WriteLineAsync( Console.WriteLine($" {session.Id} {session.Title}"); } -var handle = client.Sessions.GetSessionClient(created.Session.Id); +var handle = sessionsClient.GetSessionClient(created.Session.Id); var fetched = await handle.GetSessionAsync().ConfigureAwait(false); Console.WriteLine(string.Create( CultureInfo.InvariantCulture, diff --git a/tests/OpenCode.Sdk.Sandbox/README.md b/tests/OpenCode.Sdk.Sandbox/README.md index 3735a6951..8f6796f4f 100644 --- a/tests/OpenCode.Sdk.Sandbox/README.md +++ b/tests/OpenCode.Sdk.Sandbox/README.md @@ -11,21 +11,22 @@ Configuration comes from environment variables, prefilled for the IDE by | Variable | Meaning | |---|---| | `OPENCODE_SANDBOX_ENDPOINT` | Absolute server endpoint (required) | -| `OPENCODE_SERVER_PASSWORD` | Consumed by the SDK's own auth fallback, not by sandbox code | +| `OPENCODE_PASSWORD` / `OPENCODE_SERVER_PASSWORD` | Resolved by sandbox code and passed as `OpenCodeClientOptions.Password` — the SDK itself reads no environment | ## Running -Start a server with a fixed password so the checked-in profile matches (`serve` reads the -same `OPENCODE_SERVER_PASSWORD` variable the SDK falls back to; without it the server -generates and prints a random one): +Start a server with a fixed password so the checked-in profile matches (`serve` adopts the +same `OPENCODE_SERVER_PASSWORD` variable; without it the server generates and prints a +random one): ```sh OPENCODE_SERVER_PASSWORD=123456 opencode2 serve --hostname 127.0.0.1 --port 4096 ``` -Then F5 with the `sandbox` profile. The program drives the breadth surface end to end: -health, `CreateSessionAsync`, `ListSessionsAsync` (typed page + wire cursor), +Then F5 with the `sandbox` profile. The program composes through the Extensions package — +a Generic Host whose `AddOpenCode` registers the typed client over `IHttpClientFactory`, +with the consumer-owned `ConsoleTimingHandler` chained onto the returned +`IHttpClientBuilder` (the third extensibility rung: the SDK ships no middleware) and +`SessionsClient` resolved straight from the container — then drives the breadth surface +end to end: health, `CreateSessionAsync`, `ListSessionsAsync` (typed page + wire cursor), `GetSessionAsync` on the created handle, and `ListMessagesAsync`. - -The project deliberately stays a flat console for now; it grows a Generic Host composition -root when the Extensions package (DI) lands. diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs index 83b1d9c19..a628acbf7 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeClientContractTests.cs @@ -294,7 +294,7 @@ public async Task Client_Should_Fail_Instructively_On_The_Unoverridden_Mock_Seam [Test] public async Task Dispose_Should_Make_The_Bound_Handles_Unusable() { - var client = new OpenCodeClient(Endpoint); + var client = new OpenCodeClient(new OpenCodeClientOptions { Endpoint = Endpoint }); var session = client.Sessions.GetSessionClient("s"); client.Dispose(); @@ -312,6 +312,12 @@ public async Task Constructor_Should_Require_An_Endpoint_For_A_Byo_Client() _ = Assert.Throws(() => _ = new OpenCodeClient(httpClient, new OpenCodeClientOptions())); } + [Test] + public async Task Constructor_Should_Require_An_Endpoint_For_An_Owned_Client() + { + _ = Assert.Throws(() => _ = new OpenCodeClient(new OpenCodeClientOptions())); + } + private static OpenCodeClient CreateClient(HttpClient httpClient) => new(httpClient, new OpenCodeClientOptions { diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeClientOptionsTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeClientOptionsTests.cs new file mode 100644 index 000000000..7cdefaef1 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/OpenCodeClientOptionsTests.cs @@ -0,0 +1,14 @@ +namespace OpenCode.Sdk.Tests; + +public sealed class OpenCodeClientOptionsTests +{ + [Test] + public async Task OpenCodeClientOptions_Should_Default_To_The_Opencode_Username() + { + var options = new OpenCodeClientOptions(); + + await Assert.That(options.Username).IsEqualTo("opencode"); + await Assert.That(options.Endpoint).IsNull(); + await Assert.That(options.Password).IsNull(); + } +} diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 9db2770a4..97babcbbb 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -2,9 +2,7 @@ using System.Net.Http.Headers; using System.Reflection; using System.Text; -using NSubstitute; using OpenCode.Sdk.Internal; -using OpenCode.Sdk.Internal.Abstractions; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; @@ -12,7 +10,6 @@ namespace OpenCode.Sdk.Tests; public sealed class PipelineTests { - private const string PasswordVariable = "OPENCODE_SERVER_PASSWORD"; private static readonly Uri Endpoint = new("http://localhost:4096"); [Test] @@ -45,31 +42,46 @@ public async Task ExecuteAsync_Should_Decorate_Basic_Auth_From_The_Explicit_Pass { using var handler = new RecordingHttpHandler(); using var httpClient = new HttpClient(handler); - var environment = Substitute.For(); - using var pipeline = CreatePipeline(httpClient, password: "secret", environment: environment); + using var pipeline = CreatePipeline(httpClient, password: "secret"); _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:secret"))}"; await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); - _ = environment.DidNotReceive().GetEnvironmentVariable(Arg.Any()); } [Test] - public async Task ExecuteAsync_Should_Resolve_The_Environment_Password_Once_At_Construction() + public async Task ExecuteAsync_Should_Decorate_The_Configured_Username() { using var handler = new RecordingHttpHandler(); using var httpClient = new HttpClient(handler); - var environment = Substitute.For(); - _ = environment.GetEnvironmentVariable(PasswordVariable).Returns("fallback"); - using var pipeline = CreatePipeline(httpClient, environment: environment); + using var pipeline = CreatePipeline(httpClient, password: "secret", username: "admin"); _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secret"))}"; + await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); + } + + [Test] + public async Task Pipeline_Should_Snapshot_The_Options_At_Construction() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + var options = new OpenCodeClientOptions + { + Endpoint = Endpoint, + Password = "secret", + }; + using var pipeline = new Pipeline(httpClient, ownsHttpClient: false, options); + options.Password = "changed"; + options.Endpoint = new Uri("http://other:1"); + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); - var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:fallback"))}"; - await Assert.That(handler.Requests.All(request => request.Authorization == expected)).IsTrue(); - _ = environment.Received(1).GetEnvironmentVariable(PasswordVariable); + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:secret"))}"; + await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); + await Assert.That(handler.Requests.Single().RequestUri).IsEqualTo(new Uri("http://localhost:4096/api/health")); } [Test] @@ -83,11 +95,37 @@ public async Task Pipeline_Should_Refuse_A_Blank_Explicit_Password(string passwo var exception = Assert.Throws(() => _ = CreatePipeline(httpClient, password: password)); - await Assert.That(exception.Message).Contains("OPENCODE_SERVER_PASSWORD"); + await Assert.That(exception.Message).Contains("password"); } [Test] - public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Resolves() + [Arguments("")] + [Arguments(" ")] + public async Task Pipeline_Should_Refuse_A_Blank_Username(string username) + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + + var exception = Assert.Throws(() => + _ = CreatePipeline(httpClient, password: "secret", username: username)); + + await Assert.That(exception.Message).Contains("username"); + } + + [Test] + public async Task Pipeline_Should_Refuse_A_Username_Containing_A_Colon() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + + var exception = Assert.Throws(() => + _ = CreatePipeline(httpClient, password: "secret", username: "open:code")); + + await Assert.That(exception.Message).Contains("colon"); + } + + [Test] + public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Is_Set() { using var handler = new RecordingHttpHandler(); using var httpClient = new HttpClient(handler); @@ -413,32 +451,23 @@ public async Task Create_Should_Refuse_An_Injected_Client_Without_An_Endpoint() var exception = Assert.Throws(() => _ = Pipeline.Create( httpClient, - new OpenCodeClientOptions(), - Substitute.For())); + new OpenCodeClientOptions())); await Assert.That(exception.Message).Contains("Endpoint"); } [Test] - public async Task Create_Should_Refuse_A_Conflicting_Options_Endpoint() + public async Task Create_Should_Refuse_A_Missing_Endpoint_On_The_Owned_Path() { - var options = new OpenCodeClientOptions - { - Endpoint = new Uri("http://other:1"), - }; - - var exception = Assert.Throws(() => _ = Pipeline.Create( - Endpoint, - options, - Substitute.For())); + var exception = Assert.Throws(() => _ = Pipeline.Create(new OpenCodeClientOptions())); - await Assert.That(exception.Message).Contains("endpoint"); + await Assert.That(exception.Message).Contains("Endpoint"); } [Test] - public async Task Create_Should_Build_An_Owned_Pipeline_From_The_Endpoint() + public async Task Create_Should_Build_An_Owned_Pipeline_From_The_Options() { - using var pipeline = Pipeline.Create(Endpoint, options: null, Substitute.For()); + using var pipeline = Pipeline.Create(new OpenCodeClientOptions { Endpoint = Endpoint }); await Assert.That(pipeline).IsNotNull(); } @@ -460,6 +489,18 @@ private static Pipeline CreatePipeline( bool ownsHttpClient = false, Uri? endpoint = null, string? password = null, - IEnvironmentProvider? environment = null) => - new(httpClient, ownsHttpClient, endpoint ?? Endpoint, password, environment ?? Substitute.For()); + string? username = null) + { + var options = new OpenCodeClientOptions + { + Endpoint = endpoint ?? Endpoint, + Password = password, + }; + if (username is not null) + { + options.Username = username; + } + + return new Pipeline(httpClient, ownsHttpClient, options); + } } diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index aab318458..dab202a20 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -11,6 +11,12 @@ public required OpenCode.Sdk.Models.ServiceHealth Health { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } + public interface IOpenCodeClientOptions + { + System.Uri? Endpoint { get; } + string? Password { get; } + string Username { get; } + } public sealed class ListCursor : System.IEquatable { public ListCursor() { } @@ -55,19 +61,19 @@ public class OpenCodeClient : System.IDisposable { protected OpenCodeClient() { } - public OpenCodeClient(System.Uri endpoint) { } + public OpenCodeClient(OpenCode.Sdk.OpenCodeClientOptions options) { } public OpenCodeClient(System.Net.Http.HttpClient httpClient, OpenCode.Sdk.OpenCodeClientOptions options) { } - public OpenCodeClient(System.Uri endpoint, OpenCode.Sdk.OpenCodeClientOptions? options) { } public virtual OpenCode.Sdk.SessionsClient Sessions { 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) { } } - public sealed class OpenCodeClientOptions + public sealed class OpenCodeClientOptions : OpenCode.Sdk.IOpenCodeClientOptions { public OpenCodeClientOptions() { } public System.Uri? Endpoint { get; set; } public string? Password { get; set; } + public string Username { get; set; } } public class OpenCodeException : System.Exception { diff --git a/tests/OpenCode.Sdk.Tests/SystemEnvironmentProviderTests.cs b/tests/OpenCode.Sdk.Tests/SystemEnvironmentProviderTests.cs deleted file mode 100644 index 60e30a9c6..000000000 --- a/tests/OpenCode.Sdk.Tests/SystemEnvironmentProviderTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using OpenCode.Sdk.Internal; - -namespace OpenCode.Sdk.Tests; - -public sealed class SystemEnvironmentProviderTests -{ - [Test] - public async Task GetEnvironmentVariable_Should_Read_The_Process_Environment() - { - const string name = "OPENCODE_SDK_TEST_VARIABLE"; - Environment.SetEnvironmentVariable(name, "expected"); - try - { - var value = new SystemEnvironmentProvider().GetEnvironmentVariable(name); - - await Assert.That(value).IsEqualTo("expected"); - } - finally - { - Environment.SetEnvironmentVariable(name, null); - } - } - - [Test] - public async Task GetEnvironmentVariable_Should_Return_Null_When_The_Variable_Is_Absent() - { - var value = new SystemEnvironmentProvider().GetEnvironmentVariable("OPENCODE_SDK_TEST_ABSENT_VARIABLE"); - - await Assert.That(value).IsNull(); - } - - [Test] - public async Task GetEnvironmentVariable_Should_Refuse_A_Blank_Name() - { - _ = Assert.Throws(() => _ = new SystemEnvironmentProvider().GetEnvironmentVariable(" ")); - } -} 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 119bfe699..6350aaaf7 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 @@ -21,19 +21,10 @@ public class OpenCodeClient : IDisposable /// /// Initializes a client that owns its connection to the endpoint. /// - /// The absolute HTTP or HTTPS server endpoint. - public OpenCodeClient(Uri endpoint) : this(endpoint, options: null) + /// The client options; the endpoint is required. + public OpenCodeClient(OpenCodeClientOptions options) { - } - - /// - /// Initializes a client that owns its connection to the endpoint. - /// - /// The absolute HTTP or HTTPS server endpoint. - /// The client options; its endpoint must stay unset on this path. - public OpenCodeClient(Uri endpoint, OpenCodeClientOptions? options) - { - _pipeline = Pipeline.Create(endpoint, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(options); _widgets = new WidgetsClient(_pipeline); } @@ -41,10 +32,10 @@ public class OpenCodeClient : IDisposable /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. /// /// The caller-owned HTTP client. - /// The client options; the endpoint is required on this path. + /// The client options; the endpoint is required. public OpenCodeClient(HttpClient httpClient, OpenCodeClientOptions options) { - _pipeline = Pipeline.Create(httpClient, options, new SystemEnvironmentProvider()); + _pipeline = Pipeline.Create(httpClient, options); _widgets = new WidgetsClient(_pipeline); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index 2cddcb95c..f2cf821e0 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -19,6 +19,7 @@ internal sealed class OperationPlanBinder private static readonly string[] ReservedSpineTypeNames = [ "ErrorBehavior", + "IOpenCodeClientOptions", "ListCursor", "ListOrder", "ListRequest", diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs index aa65d4164..3c7bf0d03 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs @@ -113,34 +113,12 @@ private static IEnumerable EmitRootConstructors(ClientP yield return SyntaxFactory.ConstructorDeclaration(client.Name) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Parameter(SyntaxFactory.Identifier("endpoint")).WithType(TypeSyntaxEmitter.EmitNamed("Uri"))))) - .WithInitializer(SyntaxFactory.ConstructorInitializer( - SyntaxKind.ThisConstructorInitializer, - SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("endpoint")), - SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)) - .WithNameColon(SyntaxFactory.NameColon("options")), - ])))) - .WithBody(SyntaxFactory.Block()) - .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( - "Initializes a client that owns its connection to the endpoint.", - [new DocumentedParameter("endpoint", "The absolute HTTP or HTTPS server endpoint.")])); - yield return SyntaxFactory.ConstructorDeclaration(client.Name) - .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) - .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Parameter(SyntaxFactory.Identifier("endpoint")).WithType(TypeSyntaxEmitter.EmitNamed("Uri")), SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")) - .WithType(SyntaxFactory.NullableType(TypeSyntaxEmitter.EmitNamed("OpenCodeClientOptions"))), - ]))) - .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, "endpoint"))) + .WithType(TypeSyntaxEmitter.EmitNamed("OpenCodeClientOptions"))))) + .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, httpClientArgument: null))) .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( "Initializes a client that owns its connection to the endpoint.", - [ - new DocumentedParameter("endpoint", "The absolute HTTP or HTTPS server endpoint."), - new DocumentedParameter("options", "The client options; its endpoint must stay unset on this path."), - ])); + [new DocumentedParameter("options", "The client options; the endpoint is required.")])); yield return SyntaxFactory.ConstructorDeclaration(client.Name) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( @@ -148,17 +126,24 @@ private static IEnumerable EmitRootConstructors(ClientP SyntaxFactory.Parameter(SyntaxFactory.Identifier("httpClient")).WithType(TypeSyntaxEmitter.EmitNamed("HttpClient")), SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(TypeSyntaxEmitter.EmitNamed("OpenCodeClientOptions")), ]))) - .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, "httpClient"))) + .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, httpClientArgument: "httpClient"))) .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( "Initializes a client over a caller-owned HttpClient; the SDK never disposes it.", [ new DocumentedParameter("httpClient", "The caller-owned HTTP client."), - new DocumentedParameter("options", "The client options; the endpoint is required on this path."), + new DocumentedParameter("options", "The client options; the endpoint is required."), ])); } - private static List EmitRootAssignments(ClientPlan client, string firstArgument) + private static List EmitRootAssignments(ClientPlan client, string? httpClientArgument) { + var createArguments = new List(); + if (httpClientArgument is not null) + { + createArguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName(httpClientArgument))); + } + + createArguments.Add(SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options"))); var statements = new List { SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( @@ -166,11 +151,7 @@ private static List EmitRootAssignments(ClientPlan client, stri SyntaxFactory.IdentifierName("_pipeline"), EmissionSyntax.Invocation( EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Pipeline"), "Create"), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName(firstArgument)), - SyntaxFactory.Argument(SyntaxFactory.IdentifierName("options")), - SyntaxFactory.Argument(SyntaxFactory.ObjectCreationExpression( - TypeSyntaxEmitter.EmitNamed("SystemEnvironmentProvider")) - .WithArgumentList(SyntaxFactory.ArgumentList()))))), + [.. createArguments]))), }; statements.AddRange(client.SubClients.Select(static reference => (StatementSyntax)SyntaxFactory.ExpressionStatement( SyntaxFactory.AssignmentExpression( From 5daeffbf6471788787846cbb5bf1d5e95d048708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 17:55:18 +0300 Subject: [PATCH 27/47] docs: record the Q90 reshape seal and refresh the operational state Research log Session 22 gains the Q90 entry (options-only construction, read-only options view, Username, no SDK environment reads, factory-based AddOpenCode over IHttpClientBuilder, pooled-lifetime owned transport); the ROADMAP status paragraph carries the reshape. --- docs/ROADMAP.md | 11 ++++++---- docs/research/00-research-log.md | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 12d0d518a..a8a43ebd6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,11 +39,14 @@ and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `Invalid duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14 against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor round-tripped). The #20 decision landed (blank explicit passwords refuse; `null` keeps the -environment fallback) and #25 closed keep. The alignment batch is complete: the uniform +environment fallback) and #25 closed keep. The alignment batch is complete — the uniform `*Request` rename (Q83), the feature-slice layout migration (Q84), and the Extensions -bring-up (Q85 — `AddOpenCode` overloads registering the root client and directly -resolvable sub-clients) have all landed; further breadth batches follow the PR #26 -review cycle. +bring-up (Q85) — and the follow-on construction/options/DI reshape (research log Q90) +landed on the same PR: options-only construction with the read-only +`IOpenCodeClientOptions` view and configurable `Username`, no SDK environment reads, +`IHttpClientFactory`-based `AddOpenCode` returning the `IHttpClientBuilder`, pooled +connection lifetime on the owned transport, and the sandbox as the Generic Host DI +showcase. Further breadth batches follow the PR #26 review cycle. ## Milestones diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index 404be4b14..c5a76b977 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1868,3 +1868,38 @@ default `opencode`) — our pipeline's hardcoded `opencode` user is incomplete. password-from-environment resolution lives in the CLI consumer (`OPENCODE_PASSWORD` → legacy `OPENCODE_SERVER_PASSWORD`), not in the client library. Feeds the pending Q86 batch (options shape, env-fallback ownership, `Username` option). + +## Q90: How do client construction, options, and DI align with .NET conventions? (the in-session "Q86" batch) + +**How researched:** the maintainer's grill of the Q85 first cut plus the Q87–Q89 upstream +findings; ecosystem verification of options immutability (init-only binds via the +reflection binder; the .NET 8+ config source generator's required/init support is the +buggy edge — dotnet/runtime #95006/#101984/#90974 — so `required` stays out). + +**Decisions (maintainer, sealed):** + +- **Construction:** `OpenCodeClient(OpenCodeClientOptions)` + the caller-owned-HttpClient + overload; the Uri constructors retire — the endpoint has one home and the + endpoint-must-stay-unset guard disappears. One way to build a client. +- **Options:** the .NET convention holds — a settable class with the `Action<>` configure + pattern — and immutability is expressed at consumption: the public read-only + `IOpenCodeClientOptions` view, snapshotted by the pipeline at construction (pinned by + test: post-construction mutation never reaches a built client). +- **Credentials:** `Username` joins the options (upstream's `--username` / + `OPENCODE_SERVER_USERNAME`, default `opencode`; the pipeline's hardcode retired); + `Password` stays optional — null sends anonymous requests, matching v2's optional auth; + blank refusal stays. The environment fallback left the SDK (upstream layering: the + consumer resolves env; the CLI's own chain is `OPENCODE_PASSWORD` → + `OPENCODE_SERVER_PASSWORD`) — this revises the env half of the #20 decision on Q89 + evidence; the blank-refusal half stands. +- **Extensions:** rebuilt on `IHttpClientFactory` — `AddOpenCode(Action<...>)` + + AOT-annotated `AddOpenCode(IConfiguration)` bind options, register a transient typed + client over a named factory client, register sub-clients explicitly, and return the + `IHttpClientBuilder` so consumer handlers/resilience/telemetry chain on without SDK + middleware. The BYO-HttpClient registration overload is gone (a captured instance + defeats factory rotation; no surveyed companion package ships one). +- **Transport:** the self-created path uses `SocketsHttpHandler` with + `PooledConnectionLifetime` on modern TFMs (vision §7.5's promise, implemented); the + net472 `ServicePointManager` half stays an M3 item. +- The sandbox is the standing DI showcase (Generic Host, builder-chained consumer + handler, direct sub-client resolution). From 72bfbb96fe2cb2819ce93698bc0109e60d643555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Fri, 14 Aug 2026 23:44:01 +0300 Subject: [PATCH 28/47] =?UTF-8?q?docs(agents):=20hand=20off=20the=20M2=20c?= =?UTF-8?q?lose-out=20=E2=80=94=20Q91=20research,=20fix=20batch,=20merge,?= =?UTF-8?q?=20M3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR #26 review cycle ran through adversarial verification (36 findings: 30 confirmed, 4 plausible, 2 refuted, 10 merge blockers). Survivors are preserved durably: issues #27 (M5 naming-wall gaps), #28 (M2 curated mutual-exclusion, decision-first), #29 (M3 success-path allocations riding #23), #30 (M2 cursor PrintMembers), and seven hygiene items on #24. HANDOFF-2026-08-14-3 supersedes and deletes its predecessor: it carries the Q91 BYO-HttpClient question (recorded leaning: internalize behind the Extensions IVT; not sealed - an industry survey runs first, and a first-class-but-pragmatic default-header resolution keeps BYO acceptable), the ten-blocker fix batch with scratchpad-independent summaries and fold-ins, the merge steps, and the M3 pickup whose planning opens with the location + merged-Request design session. The ROADMAP status paragraph reflects the verification outcome and Q91 joins its open questions. --- docs/ROADMAP.md | 14 +- .../handover-prompts/HANDOFF-2026-08-14-2.md | 162 ----------------- .../handover-prompts/HANDOFF-2026-08-14-3.md | 166 ++++++++++++++++++ 3 files changed, 179 insertions(+), 163 deletions(-) delete mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md create mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a8a43ebd6..5eee57afe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,7 +46,11 @@ landed on the same PR: options-only construction with the read-only `IOpenCodeClientOptions` view and configurable `Username`, no SDK environment reads, `IHttpClientFactory`-based `AddOpenCode` returning the `IHttpClientBuilder`, pooled connection lifetime on the owned transport, and the sandbox as the Generic Host DI -showcase. Further breadth batches follow the PR #26 review cycle. +showcase. The PR #26 external review ran through adversarial verification (36 findings: +30 confirmed / 4 plausible / 2 refuted): the ten merge blockers plus small fold-ins are +queued as the fix batch in `agents/handover-prompts/HANDOFF-2026-08-14-3.md` — gated on +the open Q91 decision — and every surviving non-blocker lives in issues #27–#30 and the +#24 hygiene comment. Merge follows the fix batch; further breadth batches follow merge. ## Milestones @@ -93,6 +97,14 @@ is revisited at each milestone boundary. ## Open Questions +- **Q91 — the BYO-HttpClient surface** — review blocker #1 exposed the public + `(HttpClient, options)` constructor's default-header ambiguity. Recorded leaning: + internalize it (IVT to the Extensions package; one public constructor; the + `IHttpClientBuilder` as the sanctioned transport seam; no-DI needs met additively via + concrete options knobs). Not sealed: an industry survey runs first, and keeping BYO + public is acceptable with a first-class-but-pragmatic default-header resolution — the + proxy/TLS advantage is real. Owned by the fix-batch session + (`agents/handover-prompts/HANDOFF-2026-08-14-3.md` item A); blocker #1 waits on it. - **v2 GA watch** — the v2 line ships as `opencode2` (npm `@opencode-ai/cli@next`, desktop beta via `update.opencode.ai`) with no GA date; the spec pin stays a deliberate snapshot, refreshed at milestone boundaries. Platform detail: research doc 15. diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md b/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md deleted file mode 100644 index 555191338..000000000 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-14-2.md +++ /dev/null @@ -1,162 +0,0 @@ -# Session Handoff — M2 continuation: alignment batch, PR #26 review cycle, M3 pickup - -Date: 2026-08-14 - -Consume this document against live Git; delete it when M3 opens with its own plan. It -carries the full continuation arc after the M2 first breadth batch: the alignment batch -lands on the open PR, the PR is finalized and review-cycled, merged, and then M3 is -picked up. The three alignment decisions are sealed (research log Session 20, Q83–Q85) — -do not reopen without new evidence; the *mechanics* below are guidance, not law. - -## Onboarding Reading Order - -1. `AGENTS.md`, then every ADR (0001–0009); 0003/0004/0005/0008 are load-bearing for - this work. -2. Research log Sessions 19–20 — the sealed M2 decisions (Q76–Q82), their revision and - the alignment seals (Q83–Q85), and the execution notes. -3. `docs/engineering/coding-style.md` (§4 carries the recorded layout rule this batch - executes) and `docs/engineering/testing-style.md`. -4. `docs/ROADMAP.md` — status, milestone anchors, open questions. -5. The consumed plan `docs/superpowers/plans/2026-08-14-m2-first-breadth-batch.md` for - batch context (checkboxes closed; historical). - -## Git, Workspace, And PR State - -| Item | State | -|---|---| -| Branch | `feature/m2-first-breadth-batch` @ `39c46a9`+ (breadth batch complete, pushed) | -| PR | **#26** open against `master`; body carries the batch summary, perf numbers, live-demo transcript, review guidance, and `Closes #19/#21/#22` | -| Tree | clean; all six gates green; 787 tests | -| Issues | #20/#25 closed 2026-08-14 with rationale comments; #19/#21/#22 close at merge | -| Review templates | gitignored `.scratchpad/ultra-review-handoff.md` (+ `ultra-review/`) from the M1 cycle — reusable | - -## Work Queue (in this order) - -### A. Alignment batch — on this branch, into PR #26 - -**A1 — Uniform `*Request` rename (Q83).** Every operation input becomes -`{Group}{Subject}{Verb}Request`; the Options family retires. - -- Hand-written spine: `ListOptions.cs` → `ListRequest.cs` (abstract record, same - Limit/Order/Cursor trio — the M3 paginator seam keeps its shape under the new name). - `ListOrder`, `ListCursor`, `SessionParentFilter` unchanged. -- Generator: `OperationNamePolicy.OptionsTypeName` retires; query records derive through - `RequestTypeName`. **Deliberate consequence:** an operation declaring both a body and - query parameters derives the same type name twice and fails the existing collision - wall — the intended fail-closed state until a merged-Request design (one Request - carrying both, AWS-marshalling style) is sealed. Pin that in a refusal test. -- Internal vocabulary (`OperationOptionsPlan`, `OptionsPropertyPlan`, `OptionsEmitter`, - `OperationPlan.Options`) may rename to `Query*`/`*Request*` — internal taste; pick one - scheme, be consistent. -- Signatures: method and route-builder parameter `options` → `request`; - `requestOptions` unchanged. Update `ReservedParameterNames` to emitted reality - (`request`, `requestOptions`, `cancellationToken`, `pipeline`). -- Same-sweep fallout: regen, all emitter snapshots re-verify, PublicApi baseline - (rename-only diff — review as such), contract tests, `ListMessagesAsync` benchmark, - sandbox program, ROADMAP wording ("`ListOptions` seam" → `ListRequest`). The research - log stays untouched (dated history). Perf-neutral — no benchmark re-run. - -**A2 — Feature-slice layout migration (Q84).** Target tree (public namespaces stay -`OpenCode.Sdk` / `OpenCode.Sdk.Models` — folders are placement, not naming): - -``` -src/OpenCode.Sdk/ - OpenCodeClient.cs OpenCodeClientOptions.cs OpenCodeRequestOptions.cs - OpenCodeResponse.cs OpenCodeException.cs OpenCodeApiException.cs - OpenCodeTransportException.cs ErrorBehavior.cs OpenCodeRoutes.cs - Pagination/ ListRequest.cs ListOrder.cs ListCursor.cs - Sessions/ SessionsClient.cs SessionClient.cs SessionParentFilter.cs - SessionListRequest.cs MessageListRequest.cs Session*Response.cs - MessageListResponse.cs SessionMessageResponse.cs - Health/ HealthResponse.cs - Models/ (unchanged) - Internal/ (unchanged) -``` - -- IDE0130 arbitration for the SDK's public folders per the standing `.editorconfig` §12 - pattern — scoped to `src/OpenCode.Sdk`, never relaxed for tools or tests. -- Emitters: `GeneratedSource.RelativePath` gains the feature folder, name-blind - (family files under `{RouteContainerName}/`; root client, `OpenCodeRoutes`, registry - keep their homes). Hand-written files move with `git mv`. -- `GenerationWriter` allowlist learns the family folders fail-closed (derive admitted - folders from the plan's container names, not an open glob). Manifest paths churn once; - stale-source deletion removes old paths in the same regen. -- Tests mirror the new tree (contract tests under `Sessions/`, spine under - `Pagination/`); test namespaces stay flat under the same arbitration. - -**A3 — Extensions bring-up (Q85).** `src/OpenCode.Sdk.Extensions` is an empty shell. - -- Scope: `AddOpenCode` overloads mirroring the three client constructors (`(Uri)`, - `(Uri, Action?)`, BYO-`HttpClient`), registering the root - `OpenCodeClient` plus sub-client registrations - (`sp => sp.GetRequiredService().Sessions`) so a consumer resolves - `SessionsClient` directly; dispose stays with the container's root singleton. -- Hand-written first; generator-emitted registrations become worthwhile when client - families multiply — research-log note, not code. -- Read research doc 06 (dependency policy) before adding any `Microsoft.Extensions.*` - package; `IHttpClientFactory` integration recommended out of the first cut (additive - later). -- New `OpenCode.Sdk.Extensions.Tests` project mirroring layout; cover each overload's - composition and sub-client resolution. - -### B. Finalize PR #26 - -Update the PR body for the alignment additions (rename, layout, Extensions — one short -section; keep the existing content). The PublicApi baseline diff at this point should be -the batch surface plus renames; anything unexplained is a defect. - -### C. External review cycle on the finalized PR - -The maintainer runs multi-agent reviews **outside this harness** from hand-off -instruction files: refresh `.scratchpad/ultra-review-handoff.md` (commit range = the full -PR range, JSON result contract as in the M1 cycle). Findings come back as scratchpad -reports; **triage verifies every finding before trusting it**; blockers are fixed -red-test-first on the branch; every surviving non-blocker is preserved into -milestone-anchored GitHub issues — nothing lives only in scratchpad. - -### D. Merge - -`Closes #19/#21/#22` ride the PR body. After merge, the breadth branch retires. - -### E. M3 pickup - -- **Write the short (1–2 page) M3 plan first** — the ROADMAP convention: a milestone - gets its plan when it starts, never earlier. Align the plan with the maintainer - before executing. -- Scope pointers (ROADMAP M3 + open questions): SSE engine over the v2 stream surface - (`v2.event.subscribe`; `v2.session.log` with `after`/`follow` — resume semantics are - an open question owned by M3); the paginator/`IAsyncEnumerable` convenience over the - `ListRequest` seam, deliberately deferred from M2 to be designed with the stream - surface; #23 (union single-pass deserialization + streaming adapter-boundary - redesign), gated on the #18 performance baselines; the net472 `ServicePointManager` - item. -- **The spec-pin refresh is sanctioned at the M3 boundary** (the M2 plan froze - `a6a712a` deliberately). A refresh may move the `InvalidRequestError1` alias — its - walls are designed to fail loudly on every drift direction; that is expected signal, - not breakage. - -## Session Mechanics The Docs Don't Carry - -- Dev loop: all six gates before every commit, chained under `set -o pipefail` with an - explicit ALL-GREEN marker; commit per green increment; push on the working branch is - authorized; red-test-first is demanded. -- **Decisions to the maintainer go as plain text in chat — never AskUserQuestion - dialogs** (option-dialog annotations have been lost in transit; the maintainer - rejected the flow). Batch them with a recommendation; engage their notes in prose - before touching code — jumping to implementation mid-discussion is a standing - complaint. -- Regen ritual: edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` - → review the git diff of committed output → snapshot received→verified after review → - `generate --verify` must be a byte-identical no-op before commit. -- The PublicApi baseline diff is maintainer review surface, never auto-accepted - silently. -- Multi-TFM traps are already paid for — see the agent memory and the test csproj - comments before rediscovering them. - -## Stop Conditions - -The Arc B list carries (binder needing raw OpenAPI, name-branching emitters, structural -unions, ownership ambiguity, TFM breakage, partial packs), plus: a selected operation -carrying both a request body and query parameters (design the merged Request -deliberately before admitting it); any Extensions dependency outside research doc 06's -policy; and any M3 stream-surface design that would bypass the plan-first convention. diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md b/docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md new file mode 100644 index 000000000..bdb06ae0f --- /dev/null +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md @@ -0,0 +1,166 @@ +# Session Handoff — M2 close-out: Q91 research, verified-finding fix batch, merge, M3 pickup + +Date: 2026-08-14 + +Consume this document against live Git; delete it when M3 opens with its own plan. It +supersedes `HANDOFF-2026-08-14-2.md` (deleted with this one's creation): that handoff's +alignment batch (Q83–Q85) landed, the follow-on construction/options/DI reshape (Q90) +landed, and its external review cycle ran through adversarial verification. What remains +is the verified-finding fix batch — gated on one open decision — then merge, then M3. + +## Onboarding Reading Order + +1. `AGENTS.md`, then every ADR (0001–0009); 0003/0004/0007/0008/0009 are load-bearing + for this work. +2. Research log Sessions 19–22 (Q76–Q90) — the M2 seals, the alignment batch, the Q90 + construction/options/DI reshape, and the upstream re-research (dual-channel location, + optional auth, discovery file). +3. `docs/engineering/coding-style.md` (§4 layout) and `docs/engineering/testing-style.md`. +4. `docs/ROADMAP.md` — status, milestone anchors, open questions (Q91 listed there). +5. Review artifacts (transient, gitignored — read before the fix batch): + `.scratchpad/ultra-review-m2/REPORT.md` (finding ids 1–15), `REPORT-EXTRAS.md` + (ids 16–36), `verify/RESULTS.json` (all 36 verdicts with evidence and + `fix_assessment` — the machine input for the batch), `verify/VERIFICATION-REPORT.md` + (human synthesis). Durable extracts already live in issues #27–#30 and the #24 + comment; if scratchpad is gone, this handoff's summaries plus those issues suffice. + +## Git, Workspace, And PR State + +| Item | State | +|---|---| +| Branch | `feature/m2-first-breadth-batch` @ `5daeffb`+ (pushed); PR **#26** open against `master` | +| Tree | clean; all six gates green; 830 tests | +| Review | 36 findings adversarially verified: 30 CONFIRMED / 4 PLAUSIBLE / 2 REFUTED; **10 merge blockers** (ids 1–7, 9, 12, 14) | +| Issues | #27 (M5 naming walls), #28 (M2 curation mutual-exclusion — `needs-triage`, decision first), #29 (M3 perf, rides #23), #30 (M2 PrintMembers cursor); #24 carries seven new hygiene items as a comment | +| Open decision | **Q91 — BYO HttpClient surface** (work item A below); blocker #1's fix shape depends on it | + +## Work Queue (in this order) + +### A. Q91 — BYO-HttpClient surface: industry research, then seal (FIRST) + +Blocker #1 (default `Authorization` on an injected client leaks into requests the docs +call anonymous) exposed the cost side of the public `(HttpClient, options)` constructor. +**Deliberately not sealed** — research first. + +- **Research (fresh evidence, not recollection):** how Azure.Core/System.ClientModel, + OpenAI, AWS, Stripe.net, Octokit, and the typed-client ecosystem treat caller-supplied + transport: public BYO constructor or not; documented default-header semantics (whose + headers win); how no-DI consumers get proxy/TLS/custom handlers; whether any SDK + restricts transport injection to its DI companion package. +- **Recorded leaning (maintainer + session, 2026-08-14): internalize.** Make the + `(HttpClient, options)` constructor `internal` + `InternalsVisibleTo("OpenCode.Sdk.Extensions")` + (Tests/Performance IVTs already exist), leaving one public constructor; the Extensions + `IHttpClientBuilder` becomes the sanctioned transport-composition seam; future no-DI + needs (proxy et al.) are met additively via concrete options knobs — never a raw + handler pass-through. +- **Acceptance bar if BYO stays public:** a first-class but pragmatic — not + overengineered — resolution of the default-header ambiguity: at minimum a + construction-time fail-closed guard (`Password == null` while the injected client's + `DefaultRequestHeaders` carry `Authorization` → refuse) plus docs stating whose + headers win. The proxy/TLS advantage of BYO is real; do not discard it silently. +- Batch the research table + recommendation to the maintainer as plain text; **seal + before touching #1**. + +### B. Verified-blocker fix batch — red-test-first, commit per green increment + +Authoritative fix guidance: `verify/RESULTS.json` `fix_assessment` per id. Summaries +(durable, scratchpad-independent): + +- **#1** (Pipeline, sev 5) — shape decided by Q91; guard + doc alignment at minimum. +- **#2** (SchemaPlanBinder + ModelEmitter, sev 4) — the big one: plan gains a wire-null + bit (schema-admitted null vs optional-absence nullability), emitter gains an AOT-safe + JSON-null rejection for optional non-collection properties whose schema does not admit + null; rejection must be `JsonException` so adapters map it to + `OpenCodeTransportException`. Design before code. +- **#3** (EnvelopeDtoEmitter/EnvelopeEmitter, sev 4) — cursor-list `data` rejects null + elements on both the DTO deserialization path and the public initializer. +- **#4** (ModelEmitter, sev 4) — strict string-enum converter + (`allowIntegerValues: false`, AOT-safe, shared); regenerate the six enums. +- **#5** (SchemaNodeComparer, sev 3) — `Format` joins alias deep-identity; format-drift + refusal test (Q81 contract). +- **#6** (OperationPlanBinder, sev 4) — model-name collisions with + `ReservedSpineTypeNames` become explicit diagnostics (`UnionWith` hides them today). +- **#7** (SpecBinder, sev 3) — envelope-DTO names collision-checked against model names + before the registry `Distinct` collapses identities. +- **#9** (SpecBinder, sev 3) — property-override schema keys canonicalize through the + alias map before `TypePlanBinder`; duplicate logical keys refuse. +- **#12** (SchemaNameResolver + OperationPlanBinder, sev 3) — selected operations' + component-referenced bodies get `RequestTypeName` through a selected-ops ownership map + (pending operations must not rename shared components); `BindRequestBody` asserts the + resolved name. **Fold #8:** an explicit body+query refusal in the wire-shape check, + independent of name collision, with a component-body regression (Q83's wall must not + depend on naming luck). +- **#14** (UnionEmitter, sev 3) — concrete carrier converters get `HandleNull`; + **fold #13:** validate `UnionPlan.FixedMarker` (presence, kind, exact value) in both + carrier and base converters; keep unknown-inner-marker tolerance. + +Non-blocker fold-ins (fix in the same batch — small, all CONFIRMED): + +- **#15** owned-transport leak when the Pipeline ctor throws after `CreateOwnedHttpClient()`. +- **#20** family-folder shadow check becomes case-insensitive (`models` vs `Models/`). +- **#21** `QueryStringBuilder` escapes parameter names, not just values. +- **#23** `Username` XML doc tone: the pinned server layer hardcodes `opencode`; + `--username`/`OPENCODE_SERVER_USERNAME` is upstream direction (docs + desktop sidecar), + not pinned-server behavior. +- **#25** ROADMAP still promises the deleted env fallback (the #20-decision sentence in + the status paragraph) — align with Q90. +- **#30** `UserAgentPolicy.Resolve()` result caches statically (transient DI clients + re-reflect today). + +**Do not fix** (REFUTED): #11 (opaque-ID pass-through is the recorded convention), +#24 (premature — shape not yet admitted). **Not in this batch** (already issued): +#27, #28, #29, #30-issue, #24-comment items. + +Mechanics: regen ritual after emitter/binder changes; every fix opens red; the PublicApi +baseline diff is maintainer review surface; perf-relevant changes carry numbers. + +### C. Finalize and merge + +- PR #26 body gains one fix-batch section (keep existing content); test count updates. + `Closes #19/#21/#22` stays. +- Offer the maintainer a second, fix-diff-scoped review pass (refresh the range in + `.scratchpad/ultra-review-m2/ultra-review.md` if accepted) — their call, don't assume. +- ROADMAP status refresh rides the final batch commit. Merge on the maintainer's word; + the branch retires. + +### D. M3 pickup + +- **Write the short (1–2 page) M3 plan first**; align with the maintainer before any + execution. Planning opens with the **location + merged-Request input design session** + (sealed: research log Session 22 Q88; census and mechanisms in research doc 15 + §5a/§6 — 61 deepObject `location` ops, all 15 body+query mixes are location-caused, + `session.list` is the flat exception; server precedence query > header). +- Scope pointers: SSE engine (`v2.event.subscribe`; `v2.session.log` with + `after`/`follow`), the paginator over the `ListRequest` seam, #23 + #29 perf + (benchmark-gated), the net472 `ServicePointManager` item, spec-pin refresh sanctioned + at the boundary (the submodule pointer moves in lockstep — `spec/SNAPSHOT.md` + procedure). + +## Session Mechanics The Docs Don't Carry + +- Dev loop: all six gates before every commit, chained under `set -o pipefail` with an + explicit ALL-GREEN marker; commit per green increment; push on the working branch is + authorized; red-test-first is demanded. +- **Decisions to the maintainer go as plain text in chat — never AskUserQuestion + dialogs.** Batch them with a recommendation; engage their notes in prose before + touching code. +- Regen ritual: edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` + → review the committed-output diff → snapshots received→verified after review → + `generate --verify` byte-identical before commit. +- Multi-TFM traps are paid for — see the agent memory; new addition: Sonar's + test-project probe misses TUnit-only projects (S1075 fired on + `OpenCode.Sdk.Extensions.Tests`; arbitrate via csproj `NoWarn` + one-line + `slopwatch-ignore: SW005` comment). +- Scratchpad is transient: any fact the future needs must land in issues, docs, or this + handoff's successor before the session ends. + +## Stop Conditions + +The prior list carries (a binder needing raw OpenAPI access, name-branching emitters, +structural unions, ownership ambiguity, TFM breakage, partial packs; a body+query +operation admitted without the sealed merged-Request design; Extensions dependencies +outside research doc 06's policy; any M3 stream design bypassing plan-first), plus: + +- Executing blocker #1's fix before Q91 is sealed. +- Adding any curation surface without a maintainer seal (#28 waits for its decision). +- "Fixing" the REFUTED findings #11 or #24. From a782d34da1cbaf62c92a4caab57f247420653426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 12:07:36 +0300 Subject: [PATCH 29/47] =?UTF-8?q?docs(research):=20seal=20Q91=20=E2=80=94?= =?UTF-8?q?=20the=20caller-owned=20HttpClient=20constructor=20stays=20publ?= =?UTF-8?q?ic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven-agent primary-source survey (doc 16): no surveyed SDK gates transport behind a DI companion, none guards the anonymous default-Authorization leak, and the stock typed-client pattern requires the public constructor. Anonymous mode will fail closed at construction and per send (blocker #1's fix shape); the ROADMAP open question resolves and the stale env-fallback sentence (#25) aligns with Q90. --- docs/ROADMAP.md | 21 ++--- docs/research/00-research-log.md | 30 +++++++ docs/research/16-byo-transport-survey.md | 101 +++++++++++++++++++++++ 3 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 docs/research/16-byo-transport-survey.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5eee57afe..fb682f3ed 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Date: 2026-08-14 +Date: 2026-08-15 Operational state: what is done, what is next, what is open. This file shrinks as work lands. Evergreen rules and locked decisions live in `../AGENTS.md`; decision records in `adr/`. @@ -38,8 +38,8 @@ envelopes: `GetMessageAsync` 67.4→56.2 μs, `ListMessagesAsync` baseline 58.1 and #22 (List/Create verb rules + C17–C20) landed with it; upstream's `InvalidRequestError1` duplicate collapses through the new `schemaAliases` curation. Demonstrated live 2026-08-14 against `opencode2 serve` v0.0.0-next-17403 (create → list → get → messages, wire cursor -round-tripped). The #20 decision landed (blank explicit passwords refuse; `null` keeps the -environment fallback) and #25 closed keep. The alignment batch is complete — the uniform +round-tripped). The #20 decision landed (blank explicit passwords refuse; the environment +fallback was later removed by Q90 — `null` sends anonymous requests) and #25 closed keep. The alignment batch is complete — the uniform `*Request` rename (Q83), the feature-slice layout migration (Q84), and the Extensions bring-up (Q85) — and the follow-on construction/options/DI reshape (research log Q90) landed on the same PR: options-only construction with the read-only @@ -48,9 +48,10 @@ landed on the same PR: options-only construction with the read-only connection lifetime on the owned transport, and the sandbox as the Generic Host DI showcase. The PR #26 external review ran through adversarial verification (36 findings: 30 confirmed / 4 plausible / 2 refuted): the ten merge blockers plus small fold-ins are -queued as the fix batch in `agents/handover-prompts/HANDOFF-2026-08-14-3.md` — gated on -the open Q91 decision — and every surviving non-blocker lives in issues #27–#30 and the -#24 hygiene comment. Merge follows the fix batch; further breadth batches follow merge. +queued as the fix batch in `agents/handover-prompts/HANDOFF-2026-08-14-3.md` — Q91 sealed +2026-08-15 (research log Session 23, doc 16), unblocking blocker #1 — and every surviving +non-blocker lives in issues #27–#30 and the #24 hygiene comment. Merge follows the fix +batch; further breadth batches follow merge. ## Milestones @@ -97,14 +98,6 @@ is revisited at each milestone boundary. ## Open Questions -- **Q91 — the BYO-HttpClient surface** — review blocker #1 exposed the public - `(HttpClient, options)` constructor's default-header ambiguity. Recorded leaning: - internalize it (IVT to the Extensions package; one public constructor; the - `IHttpClientBuilder` as the sanctioned transport seam; no-DI needs met additively via - concrete options knobs). Not sealed: an industry survey runs first, and keeping BYO - public is acceptable with a first-class-but-pragmatic default-header resolution — the - proxy/TLS advantage is real. Owned by the fix-batch session - (`agents/handover-prompts/HANDOFF-2026-08-14-3.md` item A); blocker #1 waits on it. - **v2 GA watch** — the v2 line ships as `opencode2` (npm `@opencode-ai/cli@next`, desktop beta via `update.opencode.ai`) with no GA date; the spec pin stays a deliberate snapshot, refreshed at milestone boundaries. Platform detail: research doc 15. diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index c5a76b977..dccb8418d 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1903,3 +1903,33 @@ buggy edge — dotnet/runtime #95006/#101984/#90974 — so `required` stays out) net472 `ServicePointManager` half stays an M3 item. - The sandbox is the standing DI showcase (Generic Host, builder-chained consumer handler, direct sub-client resolution). + +# Session 23 — 2026-08-15: Q91 BYO-transport seal + +## Q91: Does the caller-owned `(HttpClient, options)` constructor stay public? + +**How researched:** seven-agent primary-source survey (2026-08-14, versions pinned at +release tags) across Azure.Core/System.ClientModel, OpenAI, AWS v4, Stripe.net, Octokit, +the typed-client ecosystem (Refit, Kiota, Grpc, Elastic, MCP), and the +BCL/IHttpClientFactory layer — census, findings, and sources in research doc 16; upstream +v2 JS client read at the pin (`packages/client`: public `fetch` option, SDK-wins header +precedence encoded in the generated client). + +**Found:** no surveyed SDK gates transport injection behind a DI companion (AWS v4 +removed the knob from its DI options type); every SDK resolves header conflicts by the +BCL's silent request-wins merge with zero guards, and every surveyed anonymous mode leaks +a caller default `Authorization`; the BCL cannot express "anonymous by omission" over a +caller client, and the same leak exists on the factory path via `ConfigureHttpClient` — +so the pipeline guard is required regardless of the constructor's visibility; the stock +typed-client pattern hard-requires a public `(HttpClient, …)` constructor. The +internalize+IVT leaning and a handler-seam-on-options alternative were both weighed and +rejected (doc 16 §4) — the latter on the Q90 bindable/snapshot options contract. + +**Decision (maintainer, sealed):** the constructor stays public. Anonymous mode fails +closed — `Password == null` while the injected client's `DefaultRequestHeaders` carry +`Authorization` refuses at construction and before every send (construction-only would +miss legal post-construction mutation). With `Password` set, the SDK's per-request +`Authorization` deterministically wins (BCL request-wins merge); the precedence contract +is documented on the constructor and options. Standalone handler/proxy/TLS composition +rides the BYO client; concrete convenience knobs remain an additive future. Executes as +review blocker #1's fix in this batch. diff --git a/docs/research/16-byo-transport-survey.md b/docs/research/16-byo-transport-survey.md new file mode 100644 index 000000000..0214efaa6 --- /dev/null +++ b/docs/research/16-byo-transport-survey.md @@ -0,0 +1,101 @@ +# Caller-supplied transport: BYO-HttpClient precedent survey + +Date: 2026-08-14 + +> Research snapshot, 2026-08-14. Primary sources only, retrieved fresh at pinned release +> tags: Azure SDK for .NET source (Azure.Core 1.61.0, System.ClientModel 1.15.0, +> Microsoft.Extensions.Azure 1.14.0), openai/openai-dotnet 2.13.0, aws/aws-sdk-net v4 +> (AWSSDK.Core 4.0.101), stripe/stripe-dotnet 52.3.0, octokit/octokit.net 14.0.0, +> Refit 15.0.0, Microsoft.Kiota.Http.HttpClientLibrary 2.0.0, Grpc.Net.Client 2.83.0, +> Elastic.Clients.Elasticsearch 9.5.0, ModelContextProtocol.Core 2.2.0, dotnet/runtime +> v10.0.11, learn.microsoft.com, nuget.org. Informs Q91 (research log Session 23); the +> maintainer sealed the decision — §§5–6 record analysis, not law. + +## 1. Question and stakes + +Review blocker #1 exposed the public `(HttpClient, options)` constructor's default-header +ambiguity: with v2's optional auth, `Password == null` writes no `Authorization`, and the +BCL then copies a caller client's default `Authorization` onto the nominally anonymous +request. Q91 asked whether the constructor stays public (with a first-class resolution of +the ambiguity) or transport injection narrows to the DI companion, with no-DI needs met by +concrete option knobs. + +## 2. Census + +| SDK | Caller-supplied transport | DI-gated? | Header-conflict guard | No-DI proxy/TLS | +|---|---|---|---|---| +| Azure.Core 1.61 / SCM 1.15 | Public: `options.Transport = new HttpClientTransport(httpClient)` (options property + wrapper; never a client ctor) | No — the DI companion has zero transport API | None; BCL merge decides silently | Pass-through is the documented proxy recipe; Azure.Core adds concrete TLS knobs (`HttpPipelineTransportOptions`) | +| OpenAI 2.13 | Public: inherited SCM `Transport` property; `HttpClient` absent from its public API | No | None; per-request `Set` wins silently | Pass-through only (README mTLS recipe); zero knobs by in-source design comment | +| AWS v4 (Core 4.0.101) | Public: `ClientConfig.HttpClientFactory` abstraction (factory, never a raw instance); absent on net472 | No — v4 removed the knob from its DI options type (issue aws-sdk-net#3790) | None; SigV4 per-request wins | Concrete-knobs-first (ProxyHost/Port/Credentials…), factory escape hatch; zero TLS knobs | +| Stripe.net 52.3 | Public: ctor/options param carrying a wrapper (`new SystemNetHttpClient(httpClient)`) | No — no DI package exists | None; in-source comment claims headers per-message "even when a custom HTTP client is used" | Pass-through only via the wrapper | +| Octokit 14.0 | Public: `IHttpClient` abstraction + `Func`; a live `HttpClient` cannot be passed | No — no DI package | Structurally unrepresentable in the stock path | Handler factory; proxy ergonomics admittedly poor | +| Refit 15 / Kiota 2.0 / MCP 2.2 | Public raw `HttpClient` (method param / optional ctor param / ctor param + `ownsHttpClient` flag) | No | None | Pass-through (Kiota adds one `IWebProxy` convenience) | +| Grpc.Net.Client 2.83 | Public: `GrpcChannelOptions.HttpClient` / `HttpHandler` | No | Transport-shape guards only (throws when both are set; refuses credentials over insecure channels) | `HttpHandler` pass-through | +| Elastic 9.5 | No raw `HttpClient` anywhere — transport abstraction + full concrete knob set | No (architectural, not packaging) | N/A structurally; explicit in-source precedence rule | The lone full-knobs design; pays with an internal client factory keyed on config hashes | +| opencode JS v2 (our pin, `packages/client`) | Public `fetch` option on the generated promise client | n/a | Code-defined: SDK/per-request headers overwrite client-level `headers` | n/a — a custom `fetch` is stateless; the ambient-header hazard does not exist in JS | + +## 3. Load-bearing findings + +1. **DI-gating transport injection has zero precedent.** No surveyed SDK restricts + transport to a DI companion; Azure's companion composes over the public core knob and + owns nothing; AWS v4 deliberately deleted `HttpClientFactory` from its DI options type + and pointed users back at core. Internalize-with-IVT would be an ecosystem-first. +2. **Nobody guards the header conflict, and the anonymous leak is ecosystem-universal.** + dotnet/runtime v10.0.11: default headers are copied onto a request only for names the + request does not already set (`HttpHeaders.AddHeaders` — "we don't try to merge"). + Every SDK sets auth per-request, so caller default `Authorization` is inert on + authenticated calls; in every surveyed anonymous mode (Azure credential-less clients, + AWS `AnonymousAWSCredentials`, Kiota's anonymous provider, Refit) the caller default + flows onto the wire, undocumented and unguarded. +3. **"Anonymous by omission" is impossible over a caller client.** The BCL has no + suppress-default-header mechanism; a request lacking `Authorization` inherits the + client default. An SDK with optional auth must refuse the conflict or document the + leak — and the same leak exists on the DI path (`ConfigureHttpClient` can set default + headers on the factory client), so the Pipeline guard is required regardless of where + Q91 lands. +4. **The stock typed-client pattern hard-requires a public `(HttpClient, …)` ctor.** + `AddHttpClient()` resolves constructors via `ActivatorUtilities` → + `Type.GetConstructors()` (public only); an internal ctor fails at first + `CreateClient` with `InvalidOperationException`. The sanctioned route to an internal + ctor is `AddTypedClient(Func)` — usable by our own Extensions, + invisible to consumers' stock registrations. + +## 4. Alternatives weighed + +- **Internalize + IVT to Extensions** (the pre-survey leaning): rejected on findings 1, + 3, and 4 — precedent-free gating, safety not actually gained (the guard is needed for + the factory path anyway), stock `AddHttpClient()` forecloses, and the + standalone handler/proxy/TLS door closes with no knob replacement shipped. +- **Handler seam on options** (SDK always owns the client, consumers hand + `DelegatingHandler`s — the Octokit/Kiota family): rejected on the Q90 options + contract — `OpenCodeClientOptions` is bindable data snapshotted once at construction, + and a live handler graph breaks binding, snapshotting, and the `IOptions<>` flow. + (Honest note: ASP.NET Core itself puts handlers on options-pattern types — + `JwtBearerOptions.BackchannelHttpHandler` — so the shape exists; it conflicts with our + own sealed options semantics, not with the ecosystem.) A ctor-parameter variant would + keep options pure but still break the stock typed-client pattern. +- **Transport-wrapper options property** (Azure/OpenAI shape): rejected — it pays off + only atop a pipeline/transport abstraction this SDK deliberately does not have; for us + it is the same raw `HttpClient` with the same hazard and more public surface. + +## 5. Decision (sealed — research log Session 23) + +The `(HttpClient, options)` constructor stays public. Anonymous mode fails closed: +`Password == null` while the injected client's `DefaultRequestHeaders` carry +`Authorization` is refused at construction and before every send (construction-only would +miss legal post-construction mutation of default headers). With `Password` set, the SDK's +per-request `Authorization` deterministically wins (BCL request-wins merge) and the +precedence contract is documented on the constructor and options. Standalone +handler/proxy/TLS composition rides the BYO client; concrete convenience knobs may be +added additively if demand appears. Executes as review blocker #1's fix. + +## 6. Sources + +- Azure: [ClientOptions.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/ClientOptions.cs), [HttpClientTransport.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/Pipeline/HttpClientTransport.cs), [HttpClientTransport.Request.cs](https://github.com/Azure/azure-sdk-for-net/blob/3830815f87881cce7af68dd9dd4126cbd90e197b/sdk/core/Azure.Core/src/Pipeline/HttpClientTransport.Request.cs); [Microsoft.Extensions.Azure on nuget.org](https://www.nuget.org/packages/Microsoft.Extensions.Azure) +- OpenAI: [OpenAIClient.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Custom/OpenAIClient.cs), [OpenAIClientOptions.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Custom/OpenAIClientOptions.cs), [OpenAIClientUtilities.cs](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/OpenAI/src/Utility/OpenAIClientUtilities.cs), [README](https://github.com/openai/openai-dotnet/blob/0aafb4c006c69db476607940b10c26fc07de8607/README.md) +- AWS: [ClientConfig.cs (netstandard)](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/Amazon.Runtime/_netstandard/ClientConfig.cs), [HttpRequestMessageFactory.cs](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/Amazon.Runtime/Pipeline/HttpHandler/_netstandard/HttpRequestMessageFactory.cs), [AWSConfigs.netstandard.cs](https://github.com/aws/aws-sdk-net/blob/36107286c1ac8dc5dee970e5b34584a7046e6231/sdk/src/Core/_netstandard/AWSConfigs.netstandard.cs) +- Stripe: [StripeClient.cs](https://github.com/stripe/stripe-dotnet/blob/19fb69ecd290b671b64a18c9008152bdc882cc5b/src/Stripe.net/Infrastructure/Public/StripeClient.cs), [SystemNetHttpClient.cs](https://github.com/stripe/stripe-dotnet/blob/19fb69ecd290b671b64a18c9008152bdc882cc5b/src/Stripe.net/Infrastructure/Public/SystemNetHttpClient.cs) +- Octokit: [Connection.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/Connection.cs), [HttpClientAdapter.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/HttpClientAdapter.cs), [HttpMessageHandlerFactory.cs](https://github.com/octokit/octokit.net/blob/7fa5b0fe4a18c9b981b21290c3ca9320b2d6415b/Octokit/Http/HttpMessageHandlerFactory.cs) +- Typed-client ecosystem: [RestService.cs](https://github.com/reactiveui/refit/blob/74cbb64d51b705fb52a7bc0e7bd5f4e4b62bfb5e/src/Refit/RestService.cs), [HttpClientRequestAdapter.cs](https://github.com/microsoft/kiota-dotnet/blob/2aa504cc9b566ab92f93a4f1a3a2ba2c5048a9a4/src/http/httpClient/HttpClientRequestAdapter.cs), [KiotaClientFactory.cs](https://github.com/microsoft/kiota-dotnet/blob/2aa504cc9b566ab92f93a4f1a3a2ba2c5048a9a4/src/http/httpClient/KiotaClientFactory.cs) +- BCL/guidance: [HttpClient.cs L741-779](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs#L741-L779), [HttpHeaders.cs L617-665](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/System.Net.Http/src/System/Net/Http/Headers/HttpHeaders.cs#L617-L665), [DefaultTypedHttpClientFactory.cs](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/Microsoft.Extensions.Http/src/DefaultTypedHttpClientFactory.cs), [ActivatorUtilities.cs L679-711](https://github.com/dotnet/runtime/blob/v10.0.11/src/libraries/Microsoft.Extensions.DependencyInjection.Abstractions/src/ActivatorUtilities.cs#L679-L711), [HttpClient guidelines](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines) +- Upstream parity: `external/opencode` at the pin — `packages/client/src/promise/generated/client.ts` (`baseUrl` + optional `fetch` + `headers`; merge order at lines 265–269) From b099aaa3a3fdff6d589f404b3f9258e6b7b6adaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 12:16:03 +0300 Subject: [PATCH 30/47] fix(sdk): refuse a default Authorization header on anonymous injected clients Anonymous mode cannot be expressed by omission over a caller-owned HttpClient: the BCL copies default headers onto every request that lacks the header, so a foreign credential would ride requests documented as anonymous (review blocker #1; Q91 seal). The pipeline now refuses the conflict fail-closed at construction (ArgumentException) and before every send (the typed transport spine, before any handler runs), by presence rather than parseability; with a password set the per-request header keeps winning by the BCL request-wins merge, now documented on the constructor and options. Direct-constructor and IHttpClientFactory/ConfigureHttpClient regressions pin that no request reaches the handler. --- src/OpenCode.Sdk/Internal/Pipeline.cs | 29 ++++++++++ src/OpenCode.Sdk/OpenCodeClient.cs | 2 +- src/OpenCode.Sdk/OpenCodeClientOptions.cs | 7 ++- ...penCodeServiceCollectionExtensionsTests.cs | 15 +++++ tests/OpenCode.Sdk.Tests/PipelineTests.cs | 55 +++++++++++++++++++ ...ce_Virtual_Delegating_Clients.verified.txt | 2 +- .../Generator/Emission/ClientEmitter.cs | 4 +- 7 files changed, 110 insertions(+), 4 deletions(-) diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 6829d53d5..dff417cb1 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -11,6 +11,7 @@ internal sealed class Pipeline : IDisposable { private readonly AuthenticationHeaderValue? _authorization; private readonly string _endpointBase; + private readonly bool _enforceAnonymousDefaults; private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; private readonly ProductInfoHeaderValue _userAgent; @@ -47,6 +48,18 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt nameof(options)); } + // Anonymous mode cannot be expressed by omission over an injected client: HttpClient + // copies its default headers onto every request that lacks the header, so a default + // Authorization would ride requests this SDK promises are anonymous. Refuse the + // conflict instead of silently sending a foreign credential. + if (!ownsHttpClient && password is null && CarriesDefaultAuthorization(httpClient)) + { + throw new ArgumentException( + "The options request anonymous requests (Password is null), but the supplied HttpClient's " + + "default headers carry an Authorization header. Remove the default header or set a password.", + nameof(httpClient)); + } + _httpClient = httpClient; _ownsHttpClient = ownsHttpClient; _endpointBase = EndpointPolicy.Normalize(endpoint); @@ -59,6 +72,11 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt : new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); + + // Default headers stay legally mutable after construction, so the anonymous-mode + // refusal must also run per send; owned transports never expose their client and + // authenticated sends override the default, so only anonymous injected clients pay. + _enforceAnonymousDefaults = !ownsHttpClient && _authorization is null; } public static Pipeline Create(OpenCodeClientOptions options) @@ -151,6 +169,13 @@ private async Task ExecuteCoreAsync(HttpMethod meth throw new ArgumentOutOfRangeException(nameof(options), errorBehavior, "Unknown ErrorBehavior value."); } + if (_enforceAnonymousDefaults && CarriesDefaultAuthorization(_httpClient)) + { + throw new OpenCodeTransportException( + "The anonymous opencode client's HttpClient carries a default Authorization header; the request " + + "was refused before sending. Remove the default header or set a password."); + } + int status; string rawBody; using (var request = new HttpRequestMessage(method, new Uri(_endpointBase + route, UriKind.Absolute))) @@ -194,6 +219,10 @@ public void Dispose() } } + /// Presence beats parseability: a raw unparseable default value still rides the wire. + private static bool CarriesDefaultAuthorization(HttpClient httpClient) => + httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _); + private void Decorate(HttpRequestMessage request) { if (_authorization is not null) diff --git a/src/OpenCode.Sdk/OpenCodeClient.cs b/src/OpenCode.Sdk/OpenCodeClient.cs index 65b42fbc1..e38b66db4 100644 --- a/src/OpenCode.Sdk/OpenCodeClient.cs +++ b/src/OpenCode.Sdk/OpenCodeClient.cs @@ -22,7 +22,7 @@ public OpenCodeClient(OpenCodeClientOptions options) } /// - /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. + /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. With a password set, the SDK's per-request Authorization header overrides any client default; in anonymous mode a default Authorization header is refused at construction and before every send. /// /// The caller-owned HTTP client. /// The client options; the endpoint is required. diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index 4b7bbe55b..2d4d28bdf 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -21,7 +21,12 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions /// Gets or sets the HTTP basic-authentication password. sends /// anonymous requests — a server without authentication configured expects none. An /// empty or whitespace value is refused with at client - /// construction; the SDK never reads credentials from the environment. + /// construction; the SDK never reads credentials from the environment. On a + /// caller-owned the SDK's per-request Authorization + /// header overrides any client default when a password is set; in anonymous mode a + /// default Authorization header is refused fail-closed at construction and + /// before every send, because the transport would otherwise attach that foreign + /// credential to requests this SDK promises are anonymous. /// public string? Password { get; set; } } diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs index bae604b26..9289c0659 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Headers; using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -105,6 +106,20 @@ public async Task AddOpenCode_Should_Compose_Delegating_Handlers_Through_The_Ret await Assert.That(witness.CallCount).IsEqualTo(1); } + [Test] + public async Task AddOpenCode_Should_Refuse_An_Anonymous_Factory_Client_Carrying_A_Default_Authorization() + { + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigureHttpClient(static client => + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token")); + using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => _ = provider.GetRequiredService()); + + await Assert.That(exception.Message).Contains("Authorization"); + } + [Test] public async Task AddOpenCode_Should_Refuse_A_Null_Service_Collection() { diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index 97babcbbb..be454dbf5 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -136,6 +136,61 @@ public async Task ExecuteAsync_Should_Send_Anonymously_When_No_Password_Is_Set() await Assert.That(handler.Requests.Single().Authorization).IsNull(); } + [Test] + public async Task Pipeline_Should_Refuse_An_Anonymous_Client_Whose_Defaults_Carry_Authorization() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); + + await Assert.That(exception.Message).Contains("Authorization"); + } + + [Test] + public async Task Pipeline_Should_Refuse_A_Raw_Unparseable_Default_Authorization() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + _ = httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "???"); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); + + await Assert.That(exception.Message).Contains("Authorization"); + } + + [Test] + public async Task ExecuteAsync_Should_Refuse_A_Default_Authorization_Added_After_Construction() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("Authorization"); + await Assert.That(handler.Requests).IsEmpty(); + } + + [Test] + public async Task ExecuteAsync_Should_Let_The_Explicit_Password_Win_Over_A_Default_Authorization() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "foreign-token"); + using var pipeline = CreatePipeline(httpClient, password: "secret"); + + _ = await pipeline.ExecuteAsync(HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None); + + var expected = $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes("opencode:secret"))}"; + await Assert.That(handler.Requests.Single().Authorization).IsEqualTo(expected); + } + [Test] public async Task ExecuteAsync_Should_Decorate_The_User_Agent_Per_Request() { 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 6350aaaf7..49ef1990d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt @@ -29,7 +29,7 @@ public class OpenCodeClient : IDisposable } /// - /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. + /// Initializes a client over a caller-owned HttpClient; the SDK never disposes it. With a password set, the SDK's per-request Authorization header overrides any client default; in anonymous mode a default Authorization header is refused at construction and before every send. /// /// The caller-owned HTTP client. /// The client options; the endpoint is required. diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs index 3c7bf0d03..259b089bd 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs @@ -128,7 +128,9 @@ private static IEnumerable EmitRootConstructors(ClientP ]))) .WithBody(SyntaxFactory.Block(EmitRootAssignments(client, httpClientArgument: "httpClient"))) .WithLeadingTrivia(EmissionSyntax.MemberDocumentation( - "Initializes a client over a caller-owned HttpClient; the SDK never disposes it.", + "Initializes a client over a caller-owned HttpClient; the SDK never disposes it. " + + "With a password set, the SDK's per-request Authorization header overrides any client default; " + + "in anonymous mode a default Authorization header is refused at construction and before every send.", [ new DocumentedParameter("httpClient", "The caller-owned HTTP client."), new DocumentedParameter("options", "The client options; the endpoint is required."), From 7ccb717a28dd007ad52515d8218f43ba8b69598b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 12:33:16 +0300 Subject: [PATCH 31/47] fix(sdk): reject explicit JSON null on optional properties whose schema forbids it The binder overwrote the sole nullability signal when widening optional non-collection properties for absence, so a wire null reached typed success instead of the malformed-body wall (review blocker #2). The plan now carries an AllowsWireNull bit capturing schema-admitted null (anyOf-null, plus JsonElement whose unrestricted schema admits everything) separately from the C# absence shape, and the emitter puts a shared AOT-safe rejecting converter on every optional non-collection property whose schema does not admit null: present-and-null throws JsonException, which the response adapters already map to OpenCodeTransportException; absence and true anyOf-null stay legal. Covered at the binder (synthetic + pinned parentID), the emitter snapshot, and the concrete 200 session path. --- src/OpenCode.Sdk/Internal/Pipeline.cs | 21 ++++----- .../WireNullRejectingJsonConverter.cs | 41 +++++++++++++++++ .../WireNullRejectingValueJsonConverter.cs | 42 +++++++++++++++++ src/OpenCode.Sdk/Models/LocationRef.cs | 2 + src/OpenCode.Sdk/Models/ModelRef.cs | 2 + .../Models/PromptAgentAttachment.cs | 2 + .../Models/PromptFileAttachment.cs | 4 ++ .../Models/PromptSkillAttachment.cs | 2 + src/OpenCode.Sdk/Models/SessionInfo.cs | 8 ++++ src/OpenCode.Sdk/Models/SessionInfoTime.cs | 2 + .../Models/SessionMessageAgentSelected.cs | 1 + .../Models/SessionMessageAssistant.cs | 6 +++ .../SessionMessageAssistantReasoning.cs | 1 + .../SessionMessageAssistantReasoningTime.cs | 2 + .../Models/SessionMessageAssistantSnapshot.cs | 2 + .../Models/SessionMessageAssistantTime.cs | 2 + .../Models/SessionMessageAssistantTool.cs | 1 + .../Models/SessionMessageAssistantToolTime.cs | 3 ++ .../Models/SessionMessageLocationSwitched.cs | 3 ++ .../SessionMessageLocationSwitchedPrevious.cs | 3 ++ .../Models/SessionMessageModelSelected.cs | 1 + .../Models/SessionMessageShell.cs | 2 + .../Models/SessionMessageShellTime.cs | 2 + .../Models/SessionMessageSynthetic.cs | 1 + .../Models/SessionMessageSystem.cs | 1 + src/OpenCode.Sdk/Models/SessionRevert.cs | 2 + .../Models/SessionStructuredError.cs | 2 + .../Serialization/null-parent-session.json | 24 ++++++++++ .../Serialization/OpenCodeJsonContextTests.cs | 18 ++++++++ .../Sessions/SessionClientContractTests.cs | 13 ++++++ .../Snapshots/PublicApi.verified.txt | 44 ++++++++++++++++++ .../Generator/Binding/SpecBinderTests.cs | 46 +++++++++++++++++++ .../Generator/Emission/ModelEmitterTests.cs | 11 +++++ ...ce_Immutable_Annotated_Models.verified.txt | 16 +++++++ .../Support/EmitterPlanFixture.cs | 7 ++- .../Binding/Models/ModelPropertyPlan.cs | 4 ++ .../Generator/Binding/SchemaPlanBinder.cs | 5 ++ .../Generator/Emission/ModelEmitter.cs | 42 +++++++++++++++-- 38 files changed, 374 insertions(+), 17 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingJsonConverter.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingValueJsonConverter.cs create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-parent-session.json diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index dff417cb1..ffd415b23 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -21,8 +21,7 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt { ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(options); - var endpoint = options.Endpoint - ?? throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options)); + var endpoint = options.Endpoint ?? throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options)); // Basic credentials ride the wire as "username:password", so a blank name or a colon // inside it would corrupt the header instead of failing a login. @@ -69,9 +68,7 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt // so mutating the options object after construction never changes a built client. _authorization = password is null ? null - : new AuthenticationHeaderValue( - "Basic", - Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); + : new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); // Default headers stay legally mutable after construction, so the anonymous-mode // refusal must also run per send; owned transports never expose their client and @@ -82,6 +79,7 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt public static Pipeline Create(OpenCodeClientOptions options) { ArgumentNullException.ThrowIfNull(options); + if (options.Endpoint is null) { throw new ArgumentException("OpenCodeClientOptions.Endpoint is required.", nameof(options)); @@ -96,11 +94,10 @@ public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions optio { ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(options); + if (options.Endpoint is null) { - throw new ArgumentException( - "A caller-supplied HttpClient requires OpenCodeClientOptions.Endpoint.", - nameof(options)); + throw new ArgumentException("A caller-supplied HttpClient requires OpenCodeClientOptions.Endpoint.", nameof(options)); } return new Pipeline(httpClient, ownsHttpClient: false, options); @@ -220,8 +217,7 @@ public void Dispose() } /// Presence beats parseability: a raw unparseable default value still rides the wire. - private static bool CarriesDefaultAuthorization(HttpClient httpClient) => - httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _); + private static bool CarriesDefaultAuthorization(HttpClient httpClient) => httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _); private void Decorate(HttpRequestMessage request) { @@ -257,8 +253,8 @@ private static async Task ReadBodyAsync(HttpResponseMessage response, Ca ? string.Empty : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); } - catch (Exception exception) when (exception is HttpRequestException or IOException or ObjectDisposedException - or InvalidOperationException) + catch (Exception exception) + when (exception is HttpRequestException or IOException or ObjectDisposedException or InvalidOperationException) { // InvalidOperationException covers an unusable response charset surfaced by // ReadAsStringAsync. OperationCanceledException deliberately passes through untouched. @@ -272,6 +268,7 @@ private static OpenCodeApiException CreateApiException(OpenCodeResponse response var message = response.Error is null ? $"The opencode API returned status {status}." : $"The opencode API returned status {status} ('{response.Error.Tag}')."; + return new OpenCodeApiException(message, response.Status, response.Error, response.RawBody); } } diff --git a/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingJsonConverter.cs new file mode 100644 index 000000000..c8e54650d --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingJsonConverter.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// Rejects an explicit JSON null on an optional reference-typed property whose schema does not +/// admit null; absence never invokes a property converter, so omission stays legal. +/// +/// The property's non-nullable reference type. +internal sealed class WireNullRejectingJsonConverter : JsonConverter + where T : class +{ + /// Null must reach ; the serializer would otherwise assign it silently. + public override bool HandleNull => true; + + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + throw new JsonException("The property does not admit null; the server must omit it instead."); + } + + return JsonSerializer.Deserialize(ref reader, ResolveInner(options)); + } + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value, ResolveInner(options)); + } + + /// The property attribute never replaces the type's own metadata, so this cannot recurse. + private static JsonTypeInfo ResolveInner(JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingValueJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingValueJsonConverter.cs new file mode 100644 index 000000000..c3958f796 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/WireNullRejectingValueJsonConverter.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// Rejects an explicit JSON null on an optional value-typed property whose schema does not +/// admit null; the converter targets the nullable shape because the serializer's nullable +/// wrapper would otherwise consume the null before any inner converter runs. +/// +/// The property's underlying value type. +internal sealed class WireNullRejectingValueJsonConverter : JsonConverter + where T : struct +{ + /// Null must reach ; the serializer would otherwise assign it silently. + public override bool HandleNull => true; + + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + throw new JsonException("The property does not admit null; the server must omit it instead."); + } + + return JsonSerializer.Deserialize(ref reader, ResolveInner(options)); + } + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + JsonSerializer.Serialize(writer, value.Value, ResolveInner(options)); + } + + /// The property attribute never replaces the type's own metadata, so this cannot recurse. + private static JsonTypeInfo ResolveInner(JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); +} diff --git a/src/OpenCode.Sdk/Models/LocationRef.cs b/src/OpenCode.Sdk/Models/LocationRef.cs index 3b753731e..1110040ec 100644 --- a/src/OpenCode.Sdk/Models/LocationRef.cs +++ b/src/OpenCode.Sdk/Models/LocationRef.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record LocationRef /// [JsonPropertyName("workspaceID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? WorkspaceId { get; init; } } diff --git a/src/OpenCode.Sdk/Models/ModelRef.cs b/src/OpenCode.Sdk/Models/ModelRef.cs index 4c3cd5232..23151c4a5 100644 --- a/src/OpenCode.Sdk/Models/ModelRef.cs +++ b/src/OpenCode.Sdk/Models/ModelRef.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -25,5 +26,6 @@ public sealed record ModelRef /// [JsonPropertyName("variant")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Variant { get; init; } } diff --git a/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs b/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs index 7541d366c..61e4f81d2 100644 --- a/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs +++ b/src/OpenCode.Sdk/Models/PromptAgentAttachment.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record PromptAgentAttachment /// [JsonPropertyName("mention")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public PromptMention? Mention { get; init; } } diff --git a/src/OpenCode.Sdk/Models/PromptFileAttachment.cs b/src/OpenCode.Sdk/Models/PromptFileAttachment.cs index 1bc0abf68..1a7992412 100644 --- a/src/OpenCode.Sdk/Models/PromptFileAttachment.cs +++ b/src/OpenCode.Sdk/Models/PromptFileAttachment.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -31,6 +32,7 @@ public sealed record PromptFileAttachment /// [JsonPropertyName("name")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Name { get; init; } /// @@ -38,6 +40,7 @@ public sealed record PromptFileAttachment /// [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Description { get; init; } /// @@ -45,5 +48,6 @@ public sealed record PromptFileAttachment /// [JsonPropertyName("mention")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public PromptMention? Mention { get; init; } } diff --git a/src/OpenCode.Sdk/Models/PromptSkillAttachment.cs b/src/OpenCode.Sdk/Models/PromptSkillAttachment.cs index d8eaf56d6..2fe1a3458 100644 --- a/src/OpenCode.Sdk/Models/PromptSkillAttachment.cs +++ b/src/OpenCode.Sdk/Models/PromptSkillAttachment.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -31,5 +32,6 @@ public sealed record PromptSkillAttachment /// [JsonPropertyName("mention")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public PromptMention? Mention { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionInfo.cs b/src/OpenCode.Sdk/Models/SessionInfo.cs index a0db878b6..af447ab2d 100644 --- a/src/OpenCode.Sdk/Models/SessionInfo.cs +++ b/src/OpenCode.Sdk/Models/SessionInfo.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,6 +20,7 @@ public sealed record SessionInfo /// [JsonPropertyName("parentID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? ParentId { get; init; } /// @@ -26,6 +28,7 @@ public sealed record SessionInfo /// [JsonPropertyName("fork")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionInfoFork? Fork { get; init; } /// @@ -39,6 +42,7 @@ public sealed record SessionInfo /// [JsonPropertyName("agent")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Agent { get; init; } /// @@ -46,6 +50,7 @@ public sealed record SessionInfo /// [JsonPropertyName("model")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public ModelRef? Model { get; init; } /// @@ -71,6 +76,7 @@ public sealed record SessionInfo /// [JsonPropertyName("title")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Title { get; init; } /// @@ -84,6 +90,7 @@ public sealed record SessionInfo /// [JsonPropertyName("subpath")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Subpath { get; init; } /// @@ -91,5 +98,6 @@ public sealed record SessionInfo /// [JsonPropertyName("revert")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionRevert? Revert { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionInfoTime.cs b/src/OpenCode.Sdk/Models/SessionInfoTime.cs index 771ae2d44..ad9787e90 100644 --- a/src/OpenCode.Sdk/Models/SessionInfoTime.cs +++ b/src/OpenCode.Sdk/Models/SessionInfoTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -25,5 +26,6 @@ public sealed record SessionInfoTime /// [JsonPropertyName("archived")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Archived { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs b/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs index e96712c26..1c70785a0 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAgentSelected.cs @@ -46,5 +46,6 @@ public sealed record SessionMessageAgentSelected : SessionMessageInfo /// [JsonPropertyName("previous")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Previous { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs index 71b81f857..b5a5d9000 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs @@ -66,6 +66,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("snapshot")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageAssistantSnapshot? Snapshot { get; init; } /// @@ -73,6 +74,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("finish")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public SessionMessageAssistantFinish? Finish { get; init; } /// @@ -80,6 +82,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("cost")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Cost { get; init; } /// @@ -87,6 +90,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("tokens")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public TokenUsageInfo? Tokens { get; init; } /// @@ -94,6 +98,7 @@ public required IReadOnlyList Content /// [JsonPropertyName("error")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionStructuredError? Error { get; init; } /// @@ -101,5 +106,6 @@ public required IReadOnlyList Content /// [JsonPropertyName("retry")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageAssistantRetry? Retry { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs index 381c16df8..1e9827210 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs @@ -34,5 +34,6 @@ public sealed record SessionMessageAssistantReasoning : SessionMessageAssistantC /// [JsonPropertyName("time")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageAssistantReasoningTime? Time { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs index c03d7bbfb..3460c8a05 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record SessionMessageAssistantReasoningTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs index dd8338325..ba7038bb2 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs @@ -14,6 +14,7 @@ public sealed record SessionMessageAssistantSnapshot /// [JsonPropertyName("start")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Start { get; init; } /// @@ -21,6 +22,7 @@ public sealed record SessionMessageAssistantSnapshot /// [JsonPropertyName("end")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? End { get; init; } /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs index 4d52d6158..401d73f6f 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record SessionMessageAssistantTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs index eb63d0b2e..3395a3000 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs @@ -34,6 +34,7 @@ public sealed record SessionMessageAssistantTool : SessionMessageAssistantConten /// [JsonPropertyName("executed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public bool? Executed { get; init; } /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs index 966de6236..58a61cf09 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,6 +20,7 @@ public sealed record SessionMessageAssistantToolTime /// [JsonPropertyName("ran")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Ran { get; init; } /// @@ -26,5 +28,6 @@ public sealed record SessionMessageAssistantToolTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs index 368dd3835..55019bc87 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitched.cs @@ -46,6 +46,7 @@ public sealed record SessionMessageLocationSwitched : SessionMessageInfo /// [JsonPropertyName("projectID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? ProjectId { get; init; } /// @@ -53,6 +54,7 @@ public sealed record SessionMessageLocationSwitched : SessionMessageInfo /// [JsonPropertyName("subpath")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Subpath { get; init; } /// @@ -60,5 +62,6 @@ public sealed record SessionMessageLocationSwitched : SessionMessageInfo /// [JsonPropertyName("previous")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageLocationSwitchedPrevious? Previous { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.cs b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.cs index c50418897..13884e484 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageLocationSwitchedPrevious.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,6 +20,7 @@ public sealed record SessionMessageLocationSwitchedPrevious /// [JsonPropertyName("projectID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? ProjectId { get; init; } /// @@ -26,5 +28,6 @@ public sealed record SessionMessageLocationSwitchedPrevious /// [JsonPropertyName("subpath")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Subpath { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs b/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs index 72e598d8e..079061c11 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageModelSelected.cs @@ -46,5 +46,6 @@ public sealed record SessionMessageModelSelected : SessionMessageInfo /// [JsonPropertyName("previous")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public ModelRef? Previous { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageShell.cs b/src/OpenCode.Sdk/Models/SessionMessageShell.cs index ba204ead0..231ce067b 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageShell.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageShell.cs @@ -58,6 +58,7 @@ public sealed record SessionMessageShell : SessionMessageInfo /// [JsonPropertyName("exit")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Exit { get; init; } /// @@ -65,5 +66,6 @@ public sealed record SessionMessageShell : SessionMessageInfo /// [JsonPropertyName("output")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public SessionMessageShellOutput? Output { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs b/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs index ffd023fda..07bdda8bf 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageShellTime.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -19,5 +20,6 @@ public sealed record SessionMessageShellTime /// [JsonPropertyName("completed")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public double? Completed { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs index 0a5157ff1..ede2f319a 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs @@ -40,6 +40,7 @@ public sealed record SessionMessageSynthetic : SessionMessageInfo /// [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Description { get; init; } /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageSystem.cs b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs index 9a8a53cf1..d489a5055 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageSystem.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs @@ -46,5 +46,6 @@ public sealed record SessionMessageSystem : SessionMessageInfo /// [JsonPropertyName("description")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Description { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionRevert.cs b/src/OpenCode.Sdk/Models/SessionRevert.cs index e1e011124..d2ed5b03f 100644 --- a/src/OpenCode.Sdk/Models/SessionRevert.cs +++ b/src/OpenCode.Sdk/Models/SessionRevert.cs @@ -20,6 +20,7 @@ public sealed record SessionRevert /// [JsonPropertyName("partID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? PartId { get; init; } /// @@ -27,6 +28,7 @@ public sealed record SessionRevert /// [JsonPropertyName("snapshot")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Snapshot { get; init; } /// diff --git a/src/OpenCode.Sdk/Models/SessionStructuredError.cs b/src/OpenCode.Sdk/Models/SessionStructuredError.cs index fa9a32aeb..b5f891b59 100644 --- a/src/OpenCode.Sdk/Models/SessionStructuredError.cs +++ b/src/OpenCode.Sdk/Models/SessionStructuredError.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 System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// @@ -25,5 +26,6 @@ public sealed record SessionStructuredError /// [JsonPropertyName("status")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] public long? Status { get; init; } } diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-parent-session.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-parent-session.json new file mode 100644 index 000000000..0fb8f54e0 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-parent-session.json @@ -0,0 +1,24 @@ +{ + "id": "ses_100", + "parentID": null, + "projectID": "prj_1", + "title": "Fix the build", + "cost": 0.42, + "tokens": { + "input": 10, + "output": 20, + "reasoning": 0, + "cache": { + "read": 1, + "write": 2 + } + }, + "time": { + "created": 1755100000, + "updated": 1755100050 + }, + "location": { + "directory": "/repo", + "workspaceID": "wrk_1" + } +} diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs index f4643c46b..e10de1c59 100644 --- a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -25,6 +25,24 @@ public async Task Deserialize_Should_Create_Known_Outer_Variant_When_Marker_Is_L await Assert.That(document.RootElement.GetProperty("type").GetString()).IsEqualTo("user"); } + [Test] + public async Task Deserialize_Should_Reject_Explicit_Null_On_An_Optional_Nonnull_Property() + { + var json = _fixtures.LoadJson("Serialization.null-parent-session.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Keep_Treating_An_Absent_Optional_Property_As_Absent() + { + var json = _fixtures.LoadJson("Serialization.known-session.json"); + + var session = _serializer.Deserialize(json); + + await Assert.That(session.ParentId).IsNull(); + } + [Test] public async Task Deserialize_Should_Normalize_Absent_Optional_Nonnull_Collections_To_Empty() { diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index cdc08e5d5..dad4de35d 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -26,6 +26,19 @@ await Assert.That(handler.Requests.Single().RequestUri) .IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100")); } + [Test] + public async Task GetSessionAsync_Should_Treat_An_Explicit_Null_Parent_As_A_Malformed_Success() + { + var payload = new FixtureLoader().LoadJson("Serialization.null-parent-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) + .Throws(); + } + [Test] public async Task GetSessionAsync_Should_Throw_The_Declared_404_Error() { diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index dab202a20..de27e7040 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -231,6 +231,7 @@ namespace OpenCode.Sdk.Models public LocationRef() { } [System.Text.Json.Serialization.JsonPropertyName("directory")] public required string Directory { 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; } @@ -254,6 +255,7 @@ namespace OpenCode.Sdk.Models public required string Id { get; init; } [System.Text.Json.Serialization.JsonPropertyName("providerID")] public required string ProviderId { 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("variant")] public string? Variant { get; init; } @@ -268,6 +270,7 @@ namespace OpenCode.Sdk.Models public sealed class PromptAgentAttachment : System.IEquatable { public PromptAgentAttachment() { } + [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("mention")] public OpenCode.Sdk.Models.PromptMention? Mention { get; init; } @@ -279,14 +282,17 @@ namespace OpenCode.Sdk.Models public PromptFileAttachment() { } [System.Text.Json.Serialization.JsonPropertyName("data")] public required string Data { 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("description")] public string? Description { 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("mention")] public OpenCode.Sdk.Models.PromptMention? Mention { get; init; } [System.Text.Json.Serialization.JsonPropertyName("mime")] public required string Mime { 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("name")] public string? Name { get; init; } @@ -329,6 +335,7 @@ namespace OpenCode.Sdk.Models public PromptSkillAttachment() { } [System.Text.Json.Serialization.JsonPropertyName("id")] public required string Id { 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("mention")] public OpenCode.Sdk.Models.PromptMention? Mention { get; init; } @@ -392,11 +399,13 @@ namespace OpenCode.Sdk.Models public sealed class SessionInfo : System.IEquatable { public SessionInfo() { } + [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("agent")] public string? Agent { get; init; } [System.Text.Json.Serialization.JsonPropertyName("cost")] public required double Cost { 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("fork")] public OpenCode.Sdk.Models.SessionInfoFork? Fork { get; init; } @@ -404,22 +413,27 @@ namespace OpenCode.Sdk.Models public required string Id { get; init; } [System.Text.Json.Serialization.JsonPropertyName("location")] public required OpenCode.Sdk.Models.LocationRef Location { 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("model")] public OpenCode.Sdk.Models.ModelRef? Model { 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("parentID")] public string? ParentId { get; init; } [System.Text.Json.Serialization.JsonPropertyName("projectID")] public required string ProjectId { 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("revert")] public OpenCode.Sdk.Models.SessionRevert? Revert { 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("subpath")] public string? Subpath { get; init; } [System.Text.Json.Serialization.JsonPropertyName("time")] public required OpenCode.Sdk.Models.SessionInfoTime Time { 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("title")] public string? Title { get; init; } @@ -437,6 +451,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionInfoTime : System.IEquatable { public SessionInfoTime() { } + [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("archived")] public double? Archived { get; init; } @@ -454,6 +469,7 @@ namespace OpenCode.Sdk.Models public required string Id { get; init; } [System.Text.Json.Serialization.JsonPropertyName("metadata")] public System.Collections.Generic.IReadOnlyDictionary Metadata { 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("previous")] public string? Previous { get; init; } @@ -475,12 +491,15 @@ namespace OpenCode.Sdk.Models public required string Agent { get; init; } [System.Text.Json.Serialization.JsonPropertyName("content")] public required System.Collections.Generic.IReadOnlyList Content { 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("cost")] public double? Cost { 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("error")] public OpenCode.Sdk.Models.SessionStructuredError? Error { 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("finish")] public OpenCode.Sdk.Models.SessionMessageAssistantFinish? Finish { get; init; } @@ -490,14 +509,17 @@ namespace OpenCode.Sdk.Models public System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } [System.Text.Json.Serialization.JsonPropertyName("model")] public required OpenCode.Sdk.Models.ModelRef Model { 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("retry")] public OpenCode.Sdk.Models.SessionMessageAssistantRetry? Retry { 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("snapshot")] public OpenCode.Sdk.Models.SessionMessageAssistantSnapshot? Snapshot { get; init; } [System.Text.Json.Serialization.JsonPropertyName("time")] public required OpenCode.Sdk.Models.SessionMessageAssistantTime Time { 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("tokens")] public OpenCode.Sdk.Models.TokenUsageInfo? Tokens { get; init; } @@ -534,6 +556,7 @@ namespace OpenCode.Sdk.Models public System.Collections.Generic.IReadOnlyDictionary State { get; init; } [System.Text.Json.Serialization.JsonPropertyName("text")] public required string Text { 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("time")] public OpenCode.Sdk.Models.SessionMessageAssistantReasoningTime? Time { get; init; } @@ -543,6 +566,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantReasoningTime : System.IEquatable { public SessionMessageAssistantReasoningTime() { } + [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; } @@ -562,11 +586,13 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantSnapshot : System.IEquatable { public SessionMessageAssistantSnapshot() { } + [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("end")] public string? End { get; init; } [System.Text.Json.Serialization.JsonPropertyName("files")] public System.Collections.Generic.IReadOnlyList Files { 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("start")] public string? Start { get; init; } @@ -584,6 +610,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantTime : System.IEquatable { public SessionMessageAssistantTime() { } + [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; } @@ -593,6 +620,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantTool : OpenCode.Sdk.Models.SessionMessageAssistantContent, System.IEquatable { public SessionMessageAssistantTool() { } + [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("executed")] public bool? Executed { get; init; } @@ -614,11 +642,13 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageAssistantToolTime : System.IEquatable { public SessionMessageAssistantToolTime() { } + [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("created")] public required double Created { 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("ran")] public double? Ran { get; init; } @@ -746,12 +776,15 @@ namespace OpenCode.Sdk.Models public required OpenCode.Sdk.Models.LocationRef Location { get; init; } [System.Text.Json.Serialization.JsonPropertyName("metadata")] public System.Collections.Generic.IReadOnlyDictionary Metadata { 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("previous")] public OpenCode.Sdk.Models.SessionMessageLocationSwitchedPrevious? Previous { 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("projectID")] public string? ProjectId { 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("subpath")] public string? Subpath { get; init; } @@ -765,9 +798,11 @@ namespace OpenCode.Sdk.Models public SessionMessageLocationSwitchedPrevious() { } [System.Text.Json.Serialization.JsonPropertyName("location")] public required OpenCode.Sdk.Models.LocationRef Location { 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("projectID")] public string? ProjectId { 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("subpath")] public string? Subpath { get; init; } @@ -787,6 +822,7 @@ namespace OpenCode.Sdk.Models public System.Collections.Generic.IReadOnlyDictionary Metadata { get; init; } [System.Text.Json.Serialization.JsonPropertyName("model")] public required OpenCode.Sdk.Models.ModelRef Model { 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("previous")] public OpenCode.Sdk.Models.ModelRef? Previous { get; init; } @@ -806,6 +842,7 @@ namespace OpenCode.Sdk.Models public SessionMessageShell() { } [System.Text.Json.Serialization.JsonPropertyName("command")] public required string Command { 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; } @@ -813,6 +850,7 @@ namespace OpenCode.Sdk.Models public required string Id { get; init; } [System.Text.Json.Serialization.JsonPropertyName("metadata")] public System.Collections.Generic.IReadOnlyDictionary Metadata { 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("output")] public OpenCode.Sdk.Models.SessionMessageShellOutput? Output { get; init; } @@ -852,6 +890,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageShellTime : System.IEquatable { public SessionMessageShellTime() { } + [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; } @@ -885,6 +924,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageSynthetic : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { public SessionMessageSynthetic() { } + [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("description")] public string? Description { get; init; } @@ -908,6 +948,7 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageSystem : OpenCode.Sdk.Models.SessionMessageInfo, System.IEquatable { public SessionMessageSystem() { } + [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("description")] public string? Description { get; init; } @@ -1022,9 +1063,11 @@ namespace OpenCode.Sdk.Models public System.Collections.Generic.IReadOnlyList Files { get; init; } [System.Text.Json.Serialization.JsonPropertyName("messageID")] public required string MessageId { 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("partID")] public string? PartId { 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("snapshot")] public string? Snapshot { get; init; } @@ -1034,6 +1077,7 @@ namespace OpenCode.Sdk.Models public SessionStructuredError() { } [System.Text.Json.Serialization.JsonPropertyName("message")] public required string Message { 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("status")] public long? Status { get; init; } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index dfe9a67a4..c01d0480d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -63,6 +63,52 @@ await Assert.That(errors.Variants.Select(static variant => variant.TypeName).Ord .SequenceEqual(ExpectedErrorTypeNames, StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Bind_Should_Distinguish_Wire_Null_From_Optional_Absence() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ItemInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true) + .Property("note", property => property.Type("string")) + .Property("flushedAt", property => property.AnyOf( + branch => branch.Type("number"), + branch => branch.Type("null")), required: true) + .Property("extra", property => property.Unrestricted())) + .WithOperation("v2.widget.item", path: "/api/widget/item", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ItemInfo"))))); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null)))); + + var item = plan.Models.OfType().Single(static model => model.Name == "ItemInfo"); + var id = item.Properties.Single(static property => property.WireName == "id"); + await Assert.That(id.AllowsWireNull).IsFalse(); + await Assert.That(id.Type.IsNullable).IsFalse(); + var note = item.Properties.Single(static property => property.WireName == "note"); + await Assert.That(note.AllowsWireNull).IsFalse(); + await Assert.That(note.Type.IsNullable).IsTrue(); + var flushedAt = item.Properties.Single(static property => property.WireName == "flushedAt"); + await Assert.That(flushedAt.AllowsWireNull).IsTrue(); + await Assert.That(flushedAt.Type.IsNullable).IsTrue(); + var extra = item.Properties.Single(static property => property.WireName == "extra"); + await Assert.That(extra.AllowsWireNull).IsTrue(); + } + + [Test] + public async Task Bind_Should_Keep_The_Pinned_Session_Parent_Wire_Null_Strict() + { + var (document, selection, curation) = await LoadPinnedInputsAsync(); + + var plan = new BindingTestHost().Bind(document, selection, curation); + + var session = plan.Models.OfType().Single(static model => model.Name == "SessionInfo"); + var parent = session.Properties.Single(static property => property.WireName == "parentID"); + await Assert.That(parent.AllowsWireNull).IsFalse(); + await Assert.That(parent.Type.IsNullable).IsTrue(); + } + [Test] public async Task Bind_Should_Be_Deterministic_For_The_Selected_Pin() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs index 0fa518e80..17e458799 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs @@ -21,4 +21,15 @@ public async Task Emit_Should_Route_Optional_Nonnull_Collections_Through_The_Nul await Assert.That(source).Contains("OptionalCollectionInput.Normalize"); await Assert.That(source).DoesNotContain("value is null"); } + + [Test] + public async Task Emit_Should_Reject_Wire_Null_Only_Where_The_Schema_Forbids_It() + { + var source = EmitterSnapshot.Create(ModelEmitter.Emit(EmitterPlanFixture.CreateModelSnapshot())); + + 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); + } } 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 49eeb69e1..4aa8e8a6f 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 @@ -49,8 +49,24 @@ public sealed record ExampleItem /// [JsonPropertyName("note")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingJsonConverter))] public string? Note { get; init; } + /// + /// Gets the item count. + /// + [JsonPropertyName("count")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(WireNullRejectingValueJsonConverter))] + public double? Count { get; init; } + + /// + /// Gets the flush timestamp, or null when never flushed. + /// + [JsonPropertyName("flushedAt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public double? FlushedAt { get; init; } + /// /// Gets the item tags. /// diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 241cb825e..49cafff5c 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -401,6 +401,9 @@ private static ObjectModelPlan CreateExampleItem() => [ Property("id", "ID", Named("string"), isRequired: true, "Gets the item identifier."), Property("note", "Note", Named("string", isNullable: true), isRequired: false, description: null), + Property("count", "Count", Named("double", isNullable: true), isRequired: false, "Gets the item count."), + Property("flushedAt", "FlushedAt", Named("double", isNullable: true), isRequired: false, + "Gets the flush timestamp, or null when never flushed.", allowsWireNull: true), Property("tags", "Tags", ListOf(Named("string")), isRequired: false, "Gets the item tags."), Property("links", "Links", DictionaryOf(Named("Uri")), isRequired: false, "Gets links by relation."), ], @@ -479,13 +482,14 @@ private static UnionPlan CreateOpenCodeError() => }; private static ModelPropertyPlan Property(string wireName, string name, TypeReferencePlan type, bool isRequired, - string? description) => + string? description, bool allowsWireNull = false) => new() { WireName = wireName, Name = name, Type = type, IsRequired = isRequired, + AllowsWireNull = allowsWireNull, IsLiteral = false, Description = description, }; @@ -497,6 +501,7 @@ private static ModelPropertyPlan LiteralProperty(string wireName, string name, s Name = name, Type = Named("string"), IsRequired = true, + AllowsWireNull = false, IsLiteral = true, LiteralKind = LiteralKind.String, LiteralValue = value, diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs index 38fd6bbc8..238db582d 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs @@ -12,6 +12,10 @@ internal sealed record ModelPropertyPlan public required bool IsRequired { get; init; } + /// Whether the wire contract admits an explicit JSON null, as opposed to the + /// C# nullability an optional property gains for absence alone. + public required bool AllowsWireNull { get; init; } + public required bool IsLiteral { get; init; } public LiteralKind? LiteralKind { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs index 0a332f26a..e651ef848 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs @@ -366,6 +366,10 @@ private sealed record ResolvedUnionBranch(string TargetKey, string TypeName, IRe continue; } + // Captured before optional-absence widening: schema-admitted null is what the type + // binder reported, plus JsonElement, whose unrestricted schema admits every JSON + // value including null. + var allowsWireNull = type.IsNullable || type is NamedTypeReferencePlan { Name: "JsonElement" }; if (!property.IsRequired && !type.IsCollection) { type = type with { IsNullable = true }; @@ -378,6 +382,7 @@ private sealed record ResolvedUnionBranch(string TargetKey, string TypeName, IRe Name = CSharpNamePolicy.ToPascalCase(property.Name), Type = type, IsRequired = property.IsRequired, + AllowsWireNull = allowsWireNull, IsLiteral = literal is not null, LiteralKind = literal?.Kind, LiteralValue = literal?.Value, diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs index 42500ee3e..a8e2806e6 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs @@ -9,16 +9,25 @@ namespace OpenCode.Sdk.Tools.Generator.Emission; internal static class ModelEmitter { + private static readonly IReadOnlySet PrimitiveValueTypeNames = + new HashSet(StringComparer.Ordinal) { "bool", "long", "double", }; + public static IReadOnlyList Emit(EmitPlan plan) { ArgumentNullException.ThrowIfNull(plan); var unions = plan.Unions.ToDictionary(static union => union.Name, StringComparer.Ordinal); + + // The wire-null-rejecting converter splits by class-versus-struct; generated enums are + // the only value types beyond the primitive map. + var valueTypeNames = new HashSet(PrimitiveValueTypeNames, StringComparer.Ordinal); + valueTypeNames.UnionWith(plan.Models.OfType().Select(static model => model.Name)); + var result = new List(plan.Models.Count); foreach (var model in plan.Models.OrderBy(static model => model.Name, StringComparer.Ordinal)) { result.Add(model switch { - ObjectModelPlan objectModel => EmitObject(objectModel, ResolveBaseUnion(objectModel, unions), unions), + ObjectModelPlan objectModel => EmitObject(objectModel, ResolveBaseUnion(objectModel, unions), unions, valueTypeNames), EnumModelPlan enumModel => EmitEnum(enumModel), _ => throw new InvalidOperationException($"Unknown model plan '{model.GetType().Name}'."), }); @@ -27,7 +36,8 @@ public static IReadOnlyList Emit(EmitPlan plan) return Array.AsReadOnly([.. result]); } - private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? baseUnion, IReadOnlyDictionary unions) + private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? baseUnion, IReadOnlyDictionary unions, + IReadOnlySet valueTypeNames) { // A variant overrides one abstract marker per level of its union chain: a nested // union's variant carries both its own tag and the fixed outer tag. @@ -46,7 +56,7 @@ private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? base .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)))))) + EmitProperty(property, chainMarkers.Any(wireName => IsDiscriminator(property, wireName)), valueTypeNames)))) .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Represents a {DisplayName(model.Name)} value.")); if (model.BaseTypeName is not null) { @@ -74,7 +84,8 @@ private static GeneratedSource EmitEnum(EnumModelPlan model) return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit); } - private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator) + private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator, + IReadOnlySet valueTypeNames) { var declaration = SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.Emit(property.Type), property.Name) .AddAttributeLists(EmissionSyntax.Attribute("JsonPropertyName", EmissionSyntax.StringArgument(property.WireName))) @@ -89,6 +100,19 @@ private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property .WithNameEquals(SyntaxFactory.NameEquals("Condition")))); } + if (RejectsWireNull(property) && property.Type is NamedTypeReferencePlan named) + { + // The C# type stays nullable for absence; an explicit wire null is a contract + // violation the converter turns into a JsonException. + var inner = TypeSyntaxEmitter.Emit(named with { IsNullable = false }); + var converter = TypeSyntaxEmitter.Generic( + valueTypeNames.Contains(named.Name) ? "WireNullRejectingValueJsonConverter" : "WireNullRejectingJsonConverter", + inner); + declaration = declaration.AddAttributeLists(EmissionSyntax.Attribute( + "JsonConverter", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(converter)))); + } + if (isDiscriminator) { return declaration @@ -306,12 +330,22 @@ private static IReadOnlyList CollectUsings(ObjectModelPlan model) _ = result.Add("OpenCode.Sdk.Internal.Serialization"); } + if (RejectsWireNull(property)) + { + _ = result.Add("OpenCode.Sdk.Internal.Serialization"); + } + CollectTypeUsings(property.Type, result); } return [.. result.Order(StringComparer.Ordinal)]; } + /// An optional non-collection property whose schema does not admit null rejects an + /// explicit wire null; required properties never reach the serializer as null-for-absence. + private static bool RejectsWireNull(ModelPropertyPlan property) => + !property.IsRequired && !property.Type.IsCollection && !property.AllowsWireNull; + private static void CollectTypeUsings(TypeReferencePlan type, ISet usings) { switch (type) From f74be1f79667ebd3c5e3b6af78c985561b25942c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 12:37:44 +0300 Subject: [PATCH 32/47] refactor(sdk): order Pipeline members by access and usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public surface first (constructor, factories, ExecuteAsync, Dispose), then private members in first-use order — the repo-wide member-ordering rule. --- src/OpenCode.Sdk/Internal/Pipeline.cs | 66 +++++++++++++-------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index ffd415b23..361806f23 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -103,6 +103,39 @@ public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions optio return new Pipeline(httpClient, ownsHttpClient: false, options); } + public Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter, + OpenCodeRequestOptions? options, CancellationToken cancellationToken) + where TResponse : OpenCodeResponse => + ExecuteCoreAsync(method, route, body: null, bodyTypeInfo: null, adapter, options, cancellationToken); + + public Task ExecuteAsync(HttpMethod method, string route, TBody body, + JsonTypeInfo bodyTypeInfo, ResponseAdapter adapter, OpenCodeRequestOptions? options, + CancellationToken cancellationToken) + where TBody : class + where TResponse : OpenCodeResponse + { + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(bodyTypeInfo); + return ExecuteCoreAsync(method, route, body, bodyTypeInfo, adapter, options, cancellationToken); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + /// Presence beats parseability: a raw unparseable default value still rides the wire. + private static bool CarriesDefaultAuthorization(HttpClient httpClient) => httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _); + /// The owned transport: pooled connection lifetime keeps DNS rotation alive on modern TFMs. private static HttpClient CreateOwnedHttpClient() { @@ -128,22 +161,6 @@ private static HttpClient CreateOwnedHttpClient() #endif } - public Task ExecuteAsync(HttpMethod method, string route, ResponseAdapter adapter, - OpenCodeRequestOptions? options, CancellationToken cancellationToken) - where TResponse : OpenCodeResponse => - ExecuteCoreAsync(method, route, body: null, bodyTypeInfo: null, adapter, options, cancellationToken); - - public Task ExecuteAsync(HttpMethod method, string route, TBody body, - JsonTypeInfo bodyTypeInfo, ResponseAdapter adapter, OpenCodeRequestOptions? options, - CancellationToken cancellationToken) - where TBody : class - where TResponse : OpenCodeResponse - { - ArgumentNullException.ThrowIfNull(body); - ArgumentNullException.ThrowIfNull(bodyTypeInfo); - return ExecuteCoreAsync(method, route, body, bodyTypeInfo, adapter, options, cancellationToken); - } - private async Task ExecuteCoreAsync(HttpMethod method, string route, TBody? body, JsonTypeInfo? bodyTypeInfo, ResponseAdapter adapter, OpenCodeRequestOptions? options, CancellationToken cancellationToken) @@ -202,23 +219,6 @@ private static ByteArrayContent CreateJsonContent(TBody body, JsonTypeInf return content; } - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - if (_ownsHttpClient) - { - _httpClient.Dispose(); - } - } - - /// Presence beats parseability: a raw unparseable default value still rides the wire. - private static bool CarriesDefaultAuthorization(HttpClient httpClient) => httpClient.DefaultRequestHeaders.TryGetValues("Authorization", out _); - private void Decorate(HttpRequestMessage request) { if (_authorization is not null) From 5d65711bde2e43a4ecd2f5497b72ee25fd2a72f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 12:48:10 +0300 Subject: [PATCH 33/47] fix(sdk): refuse null cursor-list pages and page elements A 200 page whose data array carried a null element deserialized into a typed success that exploded later with NullReferenceException (review blocker #3). Cursor-list DTOs now ride a shared AOT-safe converter that turns a null array or a null element into a JsonException the adapters map to OpenCodeTransportException, and the public envelope initializer's defensive copy refuses null elements with ArgumentException while keeping the error path's forgiven null behind the getter guard. Both generated cursor-list shapes are covered by contract tests and emitter snapshots. --- src/OpenCode.Sdk/Internal/Pipeline.cs | 1 + .../Serialization/ListPayloadInput.cs | 20 +++++++ .../MessageListResponseEnvelope.cs | 1 + .../NullElementRejectingListJsonConverter.cs | 60 +++++++++++++++++++ .../SessionListResponseEnvelope.cs | 1 + .../Sessions/MessageListResponse.cs | 3 +- .../Sessions/SessionListResponse.cs | 3 +- .../OpenCodeResponseTests.cs | 19 ++++++ .../Sessions/SessionClientContractTests.cs | 14 +++++ .../Sessions/SessionsClientContractTests.cs | 28 +++++++++ ...ould_Render_The_Envelope_Dtos.verified.txt | 2 + ...ce_Guarded_Response_Envelopes.verified.txt | 6 +- .../Generator/Emission/EnvelopeDtoEmitter.cs | 15 ++++- .../Generator/Emission/EnvelopeEmitter.cs | 19 +++--- 14 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/Serialization/ListPayloadInput.cs create mode 100644 src/OpenCode.Sdk/Internal/Serialization/NullElementRejectingListJsonConverter.cs diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 361806f23..b33df4988 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -171,6 +171,7 @@ private async Task ExecuteCoreAsync(HttpMethod meth ArgumentNullException.ThrowIfNull(method); ArgumentException.ThrowIfNullOrWhiteSpace(route); ArgumentNullException.ThrowIfNull(adapter); + if (route[0] is not '/') { throw new ArgumentException("Routes must start with '/'.", nameof(route)); diff --git a/src/OpenCode.Sdk/Internal/Serialization/ListPayloadInput.cs b/src/OpenCode.Sdk/Internal/Serialization/ListPayloadInput.cs new file mode 100644 index 000000000..24c4836d9 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/ListPayloadInput.cs @@ -0,0 +1,20 @@ +using System.Collections.ObjectModel; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// Copies a list payload into the envelope, refusing null elements the wire contract forbids. +internal static class ListPayloadInput +{ + internal static ReadOnlyCollection CopyRejectingNullElements(IReadOnlyList value) + where T : class + { + var copy = new T[value.Count]; + for (var index = 0; index < value.Count; index++) + { + copy[index] = value[index] + ?? throw new ArgumentException("The list payload cannot contain a null element.", nameof(value)); + } + + return Array.AsReadOnly(copy); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs index 676be9091..b699085aa 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/MessageListResponseEnvelope.cs @@ -10,6 +10,7 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed record MessageListResponseEnvelope { [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] public required IReadOnlyList Data { get; init; } [JsonPropertyName("cursor")] diff --git a/src/OpenCode.Sdk/Internal/Serialization/NullElementRejectingListJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/NullElementRejectingListJsonConverter.cs new file mode 100644 index 000000000..b624e79fe --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/NullElementRejectingListJsonConverter.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// Deserializes a cursor-list page whose wire contract admits neither a null array nor null +/// elements; either shape is a malformed success the adapters turn into a transport failure. +/// +/// The page's non-nullable element type. +internal sealed class NullElementRejectingListJsonConverter : JsonConverter> + where T : class +{ + /// Null must reach ; the serializer would otherwise assign it silently. + public override bool HandleNull => true; + + public override IReadOnlyList Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is not JsonTokenType.StartArray) + { + throw new JsonException("The page payload must be a JSON array."); + } + + var elementTypeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + var items = new List(); + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndArray) + { + return items; + } + + if (reader.TokenType is JsonTokenType.Null) + { + throw new JsonException("The page payload does not admit null elements."); + } + + items.Add(JsonSerializer.Deserialize(ref reader, elementTypeInfo) + ?? throw new JsonException("The page payload does not admit null elements.")); + } + + throw new JsonException("The page payload array was truncated."); + } + + public override void Write(Utf8JsonWriter writer, IReadOnlyList value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + + var elementTypeInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(T)); + writer.WriteStartArray(); + foreach (var item in value) + { + JsonSerializer.Serialize(writer, item, elementTypeInfo); + } + + writer.WriteEndArray(); + } +} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs index dbb658056..7a73f0e6b 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionListResponseEnvelope.cs @@ -10,6 +10,7 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed record SessionListResponseEnvelope { [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] public required IReadOnlyList Data { get; init; } [JsonPropertyName("cursor")] diff --git a/src/OpenCode.Sdk/Sessions/MessageListResponse.cs b/src/OpenCode.Sdk/Sessions/MessageListResponse.cs index 708db6b8b..13e3d0737 100644 --- a/src/OpenCode.Sdk/Sessions/MessageListResponse.cs +++ b/src/OpenCode.Sdk/Sessions/MessageListResponse.cs @@ -2,6 +2,7 @@ // 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; @@ -36,7 +37,7 @@ internal MessageListResponse(int status, OpenCodeError? error, string? rawBody) /// /// Gets the Messages payload; guarded on the error path. /// - public required IReadOnlyList Messages { get => _messages ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Messages."); init => _messages = value is null ? null : Array.AsReadOnly([.. value]); } + public required IReadOnlyList Messages { get => _messages ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Messages."); init => _messages = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } /// /// Gets the page cursor; guarded on the error path. /// diff --git a/src/OpenCode.Sdk/Sessions/SessionListResponse.cs b/src/OpenCode.Sdk/Sessions/SessionListResponse.cs index 10e0193ab..da95411ba 100644 --- a/src/OpenCode.Sdk/Sessions/SessionListResponse.cs +++ b/src/OpenCode.Sdk/Sessions/SessionListResponse.cs @@ -2,6 +2,7 @@ // 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; @@ -36,7 +37,7 @@ internal SessionListResponse(int status, OpenCodeError? error, string? rawBody) /// /// Gets the Sessions payload; guarded on the error path. /// - public required IReadOnlyList Sessions { get => _sessions ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Sessions."); init => _sessions = value is null ? null : Array.AsReadOnly([.. value]); } + public required IReadOnlyList Sessions { get => _sessions ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Sessions."); init => _sessions = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } /// /// Gets the page cursor; guarded on the error path. /// diff --git a/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs b/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs index f6625148c..eccf74601 100644 --- a/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs +++ b/tests/OpenCode.Sdk.Tests/OpenCodeResponseTests.cs @@ -1,3 +1,6 @@ +using OpenCode.Sdk.Models; +using OpenCode.Sdk.Tests.Support; + namespace OpenCode.Sdk.Tests; public sealed class OpenCodeResponseTests @@ -16,5 +19,21 @@ public async Task Constructor_Should_Default_To_The_Success_Path() await Assert.That(response.RawBody).IsNull(); } + [Test] + public async Task SessionListResponse_Should_Refuse_A_Null_Page_Element() + { + var session = new GeneratedJsonSerializer() + .Deserialize(new FixtureLoader().LoadJson("Serialization.known-session.json")); + + var exception = Assert.Throws(() => _ = new SessionListResponse + { + Status = 200, + Sessions = [session, null!], + Cursor = new ListCursor(), + }); + + await Assert.That(exception.Message).Contains("null element"); + } + private sealed record EmptyResponse : OpenCodeResponse; } diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index dad4de35d..18bab4391 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -95,6 +95,20 @@ await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri) .IsEqualTo("http://localhost:4096/api/session/ses_100/message?limit=2&order=asc"); } + [Test] + public async Task ListMessagesAsync_Should_Treat_A_Null_Page_Element_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":[null],\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").ListMessagesAsync()) + .Throws(); + } + [Test] public async Task ListMessagesAsync_Should_Throw_The_Declared_500_Error() { diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs index 26507b5b7..548cd91b5 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs @@ -50,6 +50,34 @@ await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( "http://localhost:4096/api/session?limit=5&order=desc&search=a%20b&parentID=null&cursor=cur_1"); } + [Test] + public async Task ListSessionsAsync_Should_Treat_A_Null_Page_Element_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":[null],\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + + [Test] + public async Task ListSessionsAsync_Should_Treat_A_Null_Page_As_A_Protocol_Failure() + { + using var handler = new RecordingHttpHandler(static _ => JsonResponse( + HttpStatusCode.OK, + "{\"data\":null,\"cursor\":{}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.ListSessionsAsync()) + .Throws(); + } + [Test] public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Sending() { 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 0a6705ea9..7f0ad3e99 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 @@ -27,6 +27,7 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed record WidgetItemListResponseEnvelope { [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] public required IReadOnlyList Data { get; init; } [JsonPropertyName("cursor")] @@ -62,6 +63,7 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed record WidgetListResponseEnvelope { [JsonPropertyName("data")] + [JsonConverter(typeof(NullElementRejectingListJsonConverter))] public required IReadOnlyList Data { get; init; } [JsonPropertyName("cursor")] 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 5295752cf..1d14b6950 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 @@ -128,6 +128,7 @@ public sealed record WidgetCreateResponse : OpenCodeResponse using System; using System.Diagnostics.CodeAnalysis; using System.Text; +using OpenCode.Sdk.Internal.Serialization; using OpenCode.Sdk.Models; namespace OpenCode.Sdk; @@ -162,7 +163,7 @@ public sealed record WidgetItemListResponse : OpenCodeResponse /// /// Gets the Items payload; guarded on the error path. /// - public required IReadOnlyList Items { get => _items ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Items."); init => _items = value is null ? null : Array.AsReadOnly([..value]); } + public required IReadOnlyList Items { get => _items ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Items."); init => _items = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } /// /// Gets the page cursor; guarded on the error path. /// @@ -258,6 +259,7 @@ public sealed record WidgetItemResponse : OpenCodeResponse using System; using System.Diagnostics.CodeAnalysis; using System.Text; +using OpenCode.Sdk.Internal.Serialization; using OpenCode.Sdk.Models; namespace OpenCode.Sdk; @@ -292,7 +294,7 @@ public sealed record WidgetListResponse : OpenCodeResponse /// /// Gets the Widgets payload; guarded on the error path. /// - public required IReadOnlyList Widgets { get => _widgets ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widgets."); init => _widgets = value is null ? null : Array.AsReadOnly([..value]); } + public required IReadOnlyList Widgets { get => _widgets ?? throw new InvalidOperationException("The response is an error; check IsError before accessing Widgets."); init => _widgets = value is null ? null : ListPayloadInput.CopyRejectingNullElements(value); } /// /// Gets the page cursor; guarded on the error path. /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs index e23ec60c9..dca95a9ee 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeDtoEmitter.cs @@ -28,10 +28,19 @@ .. clients private static GeneratedSource EmitDto(EnvelopePlan envelope) { - var members = new List + var data = EmitProperty("data", "Data", EmitDataType(envelope)); + if (envelope.Kind is EnvelopeKind.CursorList) { - EmitProperty("data", "Data", EmitDataType(envelope)), - }; + // 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. + data = data.AddAttributeLists(EmissionSyntax.Attribute( + "JsonConverter", + SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.Generic( + "NullElementRejectingListJsonConverter", + TypeSyntaxEmitter.EmitNamed(envelope.PayloadTypeName)))))); + } + + var members = new List { data, }; if (envelope.Kind is EnvelopeKind.CursorList) { members.Add(EmitProperty("cursor", "Cursor", TypeSyntaxEmitter.EmitNamed("ListCursor"))); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs index c6fd5d857..325e168c9 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EnvelopeEmitter.cs @@ -55,10 +55,10 @@ private static GeneratedSource EmitEnvelope(OperationPlan operation) .WithMembers(SyntaxFactory.List(members)) .WithLeadingTrivia(EmissionSyntax.Documentation( $"Represents the response of the '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' operation.")); - var unit = EmissionSyntax.CompilationUnit( - "OpenCode.Sdk", - ["System", "System.Diagnostics.CodeAnalysis", "System.Text", "OpenCode.Sdk.Models"], - [declaration]); + 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); } @@ -144,8 +144,9 @@ private static PropertyDeclarationSyntax EmitCursorProperty() => "Gets the page cursor; guarded on the error path."); /// - /// List payloads defensively copy on init so the envelope stays immutable; the error - /// path's forgiven null passes through uncopied and stays behind the getter guard. + /// 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 + /// null passes through uncopied and stays behind the getter guard. /// private static ConditionalExpressionSyntax DefensiveListCopy() => SyntaxFactory.ConditionalExpression( @@ -154,10 +155,8 @@ private static ConditionalExpressionSyntax DefensiveListCopy() => SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))), SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression), EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("Array"), "AsReadOnly"), - SyntaxFactory.Argument(SyntaxFactory.CollectionExpression( - SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.SpreadElement(SyntaxFactory.IdentifierName("value"))))))); + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ListPayloadInput"), "CopyRejectingNullElements"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("value")))); private static PropertyDeclarationSyntax EmitGuardedProperty(TypeSyntax propertyType, string propertyName, string fieldName, ExpressionSyntax initValue, string documentation) From 1b22c49aeb12b5efc573b5ca7337b87b515bf525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 12:54:03 +0300 Subject: [PATCH 34/47] fix(sdk): reject numeric wire values on generated string enums The parameterless JsonStringEnumConverter admits integers, so a numeric status in a success body surfaced as an undefined enum member instead of a malformed-body transport failure (review blocker #4). Every generated enum now rides a shared strict converter chaining to JsonStringEnumConverter(namingPolicy: null, allowIntegerValues: false); the six enums regenerate, and context plus 200-session-path tests pin numeric-status rejection alongside the known-string mapping. Union carriers (ADR-0009) are untouched. --- .../StrictJsonStringEnumConverter.cs | 18 ++++++++++ src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs | 3 +- .../Models/SessionMessageAssistantFinish.cs | 3 +- ...SessionMessageCompactionCompletedReason.cs | 3 +- .../SessionMessageCompactionFailedReason.cs | 3 +- .../SessionMessageCompactionRunningReason.cs | 3 +- .../Models/SessionMessageShellStatus.cs | 3 +- .../Serialization/known-diff-status.json | 7 ++++ .../Serialization/numeric-diff-status.json | 7 ++++ .../Serialization/numeric-status-session.json | 35 +++++++++++++++++++ .../Serialization/OpenCodeJsonContextTests.cs | 18 ++++++++++ .../Sessions/SessionClientContractTests.cs | 13 +++++++ .../Snapshots/PublicApi.verified.txt | 12 +++---- ...ce_Immutable_Annotated_Models.verified.txt | 3 +- .../Generator/Emission/ModelEmitter.cs | 9 +++-- 15 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 src/OpenCode.Sdk/Internal/Serialization/StrictJsonStringEnumConverter.cs create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-diff-status.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-diff-status.json create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-status-session.json diff --git a/src/OpenCode.Sdk/Internal/Serialization/StrictJsonStringEnumConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/StrictJsonStringEnumConverter.cs new file mode 100644 index 000000000..f19f3ef11 --- /dev/null +++ b/src/OpenCode.Sdk/Internal/Serialization/StrictJsonStringEnumConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace OpenCode.Sdk.Internal.Serialization; + +/// +/// The string-enum converter for every generated enum: wire values are strings by contract, +/// so a numeric token is a malformed body, not an undefined enum member. The parameterless +/// shape keeps the attribute reference AOT-safe. +/// +/// The generated enum type. +internal sealed class StrictJsonStringEnumConverter : JsonStringEnumConverter + where TEnum : struct, Enum +{ + public StrictJsonStringEnumConverter() + : base(namingPolicy: null, allowIntegerValues: false) + { + } +} diff --git a/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs b/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs index d71ce4696..178968b92 100644 --- a/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs +++ b/src/OpenCode.Sdk/Models/FileDiffInfoStatus.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported file diff info status values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum FileDiffInfoStatus { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs index be884f91f..258f8d18d 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantFinish.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message assistant finish values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageAssistantFinish { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs index c35159b21..c9f0dd63f 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompletedReason.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message compaction completed reason values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageCompactionCompletedReason { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs index 78fa17a9a..d785b3757 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionFailedReason.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message compaction failed reason values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageCompactionFailedReason { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs index d214ed1ff..97e112392 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionRunningReason.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message compaction running reason values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageCompactionRunningReason { /// diff --git a/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs b/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs index 0db35d57a..ed0176ab2 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageShellStatus.cs @@ -1,12 +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. using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; namespace OpenCode.Sdk.Models; /// /// Defines the supported session message shell status values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum SessionMessageShellStatus { /// diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-diff-status.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-diff-status.json new file mode 100644 index 000000000..c1d87c317 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-diff-status.json @@ -0,0 +1,7 @@ +{ + "file": "src/App.cs", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 0, + "status": "modified" +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-diff-status.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-diff-status.json new file mode 100644 index 000000000..011799193 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-diff-status.json @@ -0,0 +1,7 @@ +{ + "file": "src/App.cs", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 0, + "status": 3 +} diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-status-session.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-status-session.json new file mode 100644 index 000000000..243d6cab5 --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/numeric-status-session.json @@ -0,0 +1,35 @@ +{ + "id": "ses_100", + "projectID": "prj_1", + "title": "Fix the build", + "cost": 0.42, + "tokens": { + "input": 10, + "output": 20, + "reasoning": 0, + "cache": { + "read": 1, + "write": 2 + } + }, + "time": { + "created": 1755100000, + "updated": 1755100050 + }, + "location": { + "directory": "/repo", + "workspaceID": "wrk_1" + }, + "revert": { + "messageID": "msg_1", + "files": [ + { + "file": "src/App.cs", + "patch": "@@ -1 +1 @@", + "additions": 1, + "deletions": 0, + "status": 3 + } + ] + } +} diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs index e10de1c59..ecd726a3c 100644 --- a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -33,6 +33,24 @@ public async Task Deserialize_Should_Reject_Explicit_Null_On_An_Optional_Nonnull _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); } + [Test] + public async Task Deserialize_Should_Reject_A_Numeric_String_Enum_Value() + { + var json = _fixtures.LoadJson("Serialization.numeric-diff-status.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Map_A_Known_String_Enum_Wire_Value() + { + var json = _fixtures.LoadJson("Serialization.known-diff-status.json"); + + var diff = _serializer.Deserialize(json); + + await Assert.That(diff.Status).IsEqualTo(FileDiffInfoStatus.Modified); + } + [Test] public async Task Deserialize_Should_Keep_Treating_An_Absent_Optional_Property_As_Absent() { diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index 18bab4391..8a4be2e06 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -39,6 +39,19 @@ public async Task GetSessionAsync_Should_Treat_An_Explicit_Null_Parent_As_A_Malf .Throws(); } + [Test] + public async Task GetSessionAsync_Should_Treat_A_Numeric_Enum_Status_As_A_Malformed_Success() + { + var payload = new FixtureLoader().LoadJson("Serialization.numeric-status-session.json"); + using var handler = new RecordingHttpHandler(_ => JsonResponse(HttpStatusCode.OK, $"{{\"data\":{payload}}}")); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + _ = await Assert + .That(async () => _ = await client.Sessions.GetSessionClient("ses_100").GetSessionAsync()) + .Throws(); + } + [Test] public async Task GetSessionAsync_Should_Throw_The_Declared_404_Error() { diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index de27e7040..da9f3f245 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -194,7 +194,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("status")] public required OpenCode.Sdk.Models.FileDiffInfoStatus Status { get; init; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum FileDiffInfoStatus { [System.Text.Json.Serialization.JsonStringEnumMemberName("added")] @@ -533,7 +533,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public abstract string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageAssistantFinish { [System.Text.Json.Serialization.JsonStringEnumMemberName("stop")] @@ -680,7 +680,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageCompactionCompletedReason { [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")] @@ -712,7 +712,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageCompactionFailedReason { [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")] @@ -746,7 +746,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public override string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageCompactionRunningReason { [System.Text.Json.Serialization.JsonStringEnumMemberName("auto")] @@ -875,7 +875,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("truncated")] public required bool Truncated { get; init; } } - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionMessageShellStatus { [System.Text.Json.Serialization.JsonStringEnumMemberName("running")] 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 4aa8e8a6f..f8a8c691f 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 @@ -84,12 +84,13 @@ public sealed record ExampleItem // 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 example mode values. /// -[JsonConverter(typeof(JsonStringEnumConverter))] +[JsonConverter(typeof(StrictJsonStringEnumConverter))] public enum ExampleMode { /// diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs index a8e2806e6..faf0dfd83 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs @@ -71,7 +71,9 @@ private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? base private static GeneratedSource EmitEnum(EnumModelPlan model) { - var converterType = TypeSyntaxEmitter.Generic("JsonStringEnumConverter", TypeSyntaxEmitter.EmitNamed(model.Name)); + // Strict by contract: the schema types enum values as strings, so the permissive + // default's integer tolerance would admit malformed bodies as undefined members. + var converterType = TypeSyntaxEmitter.Generic("StrictJsonStringEnumConverter", TypeSyntaxEmitter.EmitNamed(model.Name)); var declaration = SyntaxFactory.EnumDeclaration(model.Name) .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .AddAttributeLists(EmissionSyntax.Attribute("JsonConverter", SyntaxFactory.AttributeArgument( @@ -80,7 +82,10 @@ private static GeneratedSource EmitEnum(EnumModelPlan model) .AddAttributeLists(EmissionSyntax.Attribute("JsonStringEnumMemberName", EmissionSyntax.StringArgument(value.WireValue))) .WithLeadingTrivia(EmissionSyntax.Documentation($"Represents the '{value.WireValue}' wire value."))))) .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Defines the supported {DisplayName(model.Name)} values.")); - var unit = EmissionSyntax.CompilationUnit(model.Namespace, ["System.Text.Json.Serialization"], [declaration]); + var unit = EmissionSyntax.CompilationUnit( + model.Namespace, + ["System.Text.Json.Serialization", "OpenCode.Sdk.Internal.Serialization"], + [declaration]); return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit); } From ae73e8a1f47a84551d99b058fd86e835338d0e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 12:58:33 +0300 Subject: [PATCH 35/47] fix(tools): make format part of schema-alias deep identity Alias identity compared primitive nodes by kind alone even though format picks the emitted CLR type ('format: uri' emits Uri), so a format-drifted duplicate could collapse silently instead of tripping Q81's fail-closed drift wall (review blocker #5). Format now compares ordinally ahead of the node-kind switch, riding every recursive comparison; a format-only alias divergence refuses. --- .../Generator/Binding/SpecBinderTests.cs | 10 ++++++++++ .../Generator/Binding/SchemaNodeComparer.cs | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index c01d0480d..3a5c27cda 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -549,6 +549,16 @@ public async Task Bind_Should_Refuse_An_Alias_Whose_Shapes_Differ() await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError"), "structurally identical"); } + [Test] + public async Task Bind_Should_Refuse_An_Alias_Whose_Formats_Differ() + { + var document = await IngestAsync(DuplicateTagScenario(static duplicate => duplicate.Type("object") + .Property("_tag", property => property.Type("string").Enum("GadgetError"), required: true) + .Property("message", property => property.Type("string").Format("uri"), required: true))); + + await AssertAliasRefusalAsync(document, Alias("GadgetError1", "GadgetError"), "structurally identical"); + } + [Test] public async Task Bind_Should_Refuse_An_Alias_Whose_Schema_Is_Missing() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs index 6d1dd842d..d9de7bcce 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNodeComparer.cs @@ -13,6 +13,13 @@ public static bool DeepEquals(SchemaNode left, SchemaNode right) ArgumentNullException.ThrowIfNull(left); ArgumentNullException.ThrowIfNull(right); + // 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, From 64a94dc9d57e76ffd39e94d202ce91bc65d69306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 13:04:11 +0300 Subject: [PATCH 36/47] fix(tools): refuse model names colliding with hand-written spine types The collision wall bulk-unioned model names with ReservedSpineTypeNames, silently swallowing duplicates such as a schema named ListCursor whose unqualified emitted references would then resolve to the pagination spine type (review blocker #6). Reserved names now seed the owner set and each model name joins individually; a model/spine duplicate reports a deterministic naming diagnostic and no emit plan escapes. --- .../Binding/OperationPlanBinderTests.cs | 18 ++++++++++++++++++ .../Generator/Binding/OperationPlanBinder.cs | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 2724d48a0..6613d6c7a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -214,6 +214,24 @@ await Assert.That(part.Parameters.Select(static parameter => parameter.WireName) .SequenceEqual(["gadgetID", "partID"], StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Bind_Should_Refuse_A_Model_Colliding_With_A_Spine_Type_Name() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("ListCursor", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.item", path: "/api/widget/item", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("ListCursor"))))); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.widget.item"), + Curation(Groups("widget", ClientGroup(clientName: "Widgets", handleName: null, handleParameter: null))))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("spine", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Refuse_An_Unsupported_Http_Method() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index f2cf821e0..de2932fb6 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -208,10 +208,20 @@ private void CheckTypeNameCollisions(List clients, List typeNames, BindingErrorCollector errors) { // Model names shadow across namespaces (consumer CS0104) and hand-written spine - // names collide outright (CS0101), so both seed the owner set. + // names collide outright (CS0101), so both seed the owner set — reserved names + // first, each model name individually, because a bulk union would swallow a + // model/spine duplicate instead of refusing it. var owners = new HashSet(_comparer); - owners.UnionWith(typeNames.Values); owners.UnionWith(ReservedSpineTypeNames); + foreach (var entry in typeNames + .Where(entry => !owners.Add(entry.Value) && ReservedSpineTypeNames.Contains(entry.Value, _comparer)) + .OrderBy(static entry => entry.Key, StringComparer.Ordinal)) + { + errors.Add( + BindingErrorCategory.Naming, + entry.Key, + $"model type name '{entry.Value}' collides with the hand-written spine type '{entry.Value}'"); + } foreach (var name in clients.Select(static client => client.Name).Where(name => !owners.Add(name))) { errors.Add(BindingErrorCategory.Naming, name, $"client type name '{name}' collides with another generated type"); From 9dd4a37ab3b9ed008a5a56d9e1a5f69a557372be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 13:10:00 +0300 Subject: [PATCH 37/47] fix(tools): collision-check envelope DTO names against model names Registry composition deduplicated model and DTO names with a Distinct, so an operation-derived DTO name matching a component model collapsed two serializer identities and made the emitted DTO recursively typed instead of refusing (review blocker #7). DTO names now validate against the schema registry and against each other before composition, with deterministic naming diagnostics; the Distinct is gone. --- .../Binding/OperationPlanBinderTests.cs | 30 +++++++++++++++ .../Generator/Binding/SpecBinder.cs | 38 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 6613d6c7a..6882d5933 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -448,6 +448,36 @@ await Assert.That(plan.Registry.TypeNames .SequenceEqual(["WidgetInfo", "WidgetListResponseEnvelope"], StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Bind_Should_Refuse_An_Envelope_Dto_Name_Colliding_With_A_Model() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true) + .Property("extra", property => property.Ref("WidgetListResponseEnvelope"), required: true)) + .WithSchema("WidgetListResponseEnvelope", schema => schema.Type("object") + .Property("note", property => property.Type("string"), required: true)) + .WithSchema("WidgetsResponse", schema => schema.Type("object") + .AdditionalPropertiesFalse() + .Property("data", property => property.Type("array") + .Items(static item => item.Ref("WidgetInfo")), required: true) + .Property("cursor", cursor => cursor.Type("object") + .AdditionalPropertiesFalse() + .Property("previous", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))) + .Property("next", static property => property.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null"))), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetsResponse"))))); + + var exception = Assert.Throws(() => _ = BindWidgets(document)); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming + && error.Problem.Contains("envelope DTO", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Bind_A_Component_Data_Envelope_Without_Modeling_The_Wrapper() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 8e8dc74c6..9c3fbc4eb 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -58,6 +58,7 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }) .ToArray(); + CheckDtoNameCollisions(schemaResult.Registry, clients, errors); errors.ThrowIfAny(); return new EmitPlan { @@ -70,6 +71,40 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera }; } + /// + /// 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 + /// directions refuse before composition instead of hiding behind a Distinct. + /// + private static void CheckDtoNameCollisions(RegistryPlan schemaRegistry, IReadOnlyList clients, + BindingErrorCollector errors) + { + var modelNames = schemaRegistry.TypeNames.ToHashSet(StringComparer.Ordinal); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var dtoName in clients + .SelectMany(static client => client.Operations) + .Select(static operation => operation.Envelope.EnvelopeDtoTypeName) + .OfType() + .OrderBy(static name => name, StringComparer.Ordinal)) + { + if (modelNames.Contains(dtoName)) + { + errors.Add( + BindingErrorCategory.Naming, + dtoName, + $"envelope DTO name '{dtoName}' collides with a generated model name"); + } + + if (!seen.Add(dtoName)) + { + errors.Add( + BindingErrorCategory.Naming, + dtoName, + $"envelope DTO name '{dtoName}' is derived by multiple operations"); + } + } + } + /// Wrapped envelopes deserialize through internal DTOs, which join the serializer registry. private static RegistryPlan ComposeRegistry(RegistryPlan schemaRegistry, IReadOnlyList clients) { @@ -79,7 +114,8 @@ private static RegistryPlan ComposeRegistry(RegistryPlan schemaRegistry, IReadOn .OfType(); return new RegistryPlan { - TypeNames = [.. schemaRegistry.TypeNames.Concat(dtoNames).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)], + // Uniqueness is guaranteed by CheckDtoNameCollisions before composition. + TypeNames = [.. schemaRegistry.TypeNames.Concat(dtoNames).Order(StringComparer.Ordinal)], }; } From 1c8b1907f503f05a94a2c5b00173e8ca259e9975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 13:16:04 +0300 Subject: [PATCH 38/47] fix(tools): canonicalize property-override keys through the alias map An override keyed to an alias source validated cleanly, then vanished after collapse because the type binder looked it up under the target key (review blocker #9). Override schema keys now map through the alias collapse before type binding, and rows landing on the same logical (schema, property) after resolution refuse with a curation diagnostic instead of one silently winning. Regressions cover the reachable-source override and the source-plus-target collision. --- .../Generator/Binding/SpecBinderTests.cs | 40 ++++++++++++++++++ .../Generator/Binding/SpecBinder.cs | 41 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs index 3a5c27cda..9037e22d1 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs @@ -524,6 +524,46 @@ await Assert.That(plan.Unions.Single(static union => union.Name == "OpenCodeErro .SequenceEqual(["GadgetError"], StringComparer.Ordinal)).IsTrue(); } + [Test] + public async Task Bind_Should_Apply_A_Property_Override_Keyed_To_An_Alias_Source() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var plan = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + Curation( + Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null)), + propertyOverrides: [new PropertyOverride { Schema = "GadgetError1", Property = "message", Type = PropertyOverrideType.Uri, Reason = "The wire value is URL-semantic." }], + schemaAliases: [Alias("GadgetError1", "GadgetError")])); + + var error = plan.Models.OfType().Single(static model => model.Name == "GadgetError"); + var message = error.Properties.Single(static property => property.WireName == "message"); + await Assert.That(message.Type).IsTypeOf(); + await Assert.That(((NamedTypeReferencePlan)message.Type).Name).IsEqualTo("Uri"); + } + + [Test] + public async Task Bind_Should_Refuse_Overrides_Collapsing_Onto_The_Same_Property() + { + var document = await IngestAsync(DuplicateTagScenario()); + + var exception = Assert.Throws(() => _ = new BindingTestHost().Bind( + document, + Selection("v2.gadget.get"), + Curation( + Groups("gadget", ClientGroup(clientName: "Gadgets", handleName: null, handleParameter: null)), + propertyOverrides: + [ + new PropertyOverride { Schema = "GadgetError1", Property = "message", Type = PropertyOverrideType.Uri, Reason = "The wire value is URL-semantic." }, + new PropertyOverride { Schema = "GadgetError", Property = "message", Type = PropertyOverrideType.Uri, Reason = "The wire value is URL-semantic." }, + ], + schemaAliases: [Alias("GadgetError1", "GadgetError")]))); + + await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation + && error.Problem.Contains("collapse", StringComparison.Ordinal))).IsTrue(); + } + [Test] public async Task Bind_Should_Refuse_A_Duplicate_Error_Tag_Without_An_Alias() { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 9c3fbc4eb..7bcc0e607 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -41,6 +41,10 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera 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); + curation = curation with + { + PropertyOverrides = MapOverrideKeys(curation.PropertyOverrides, curation.SchemaAliases, errors), + }; } // Type names are resolved exactly once per bind; schema and operation binding @@ -119,6 +123,43 @@ private static RegistryPlan ComposeRegistry(RegistryPlan schemaRegistry, IReadOn }; } + /// + /// Property overrides follow the same collapse as reachable keys: a row keyed to an + /// alias source must land on the canonical schema the type binder actually looks up, + /// and two rows collapsing onto one logical property refuse instead of one silently + /// winning. + /// + private static ReadOnlyCollection MapOverrideKeys(IReadOnlyList overrides, + IReadOnlyList aliases, BindingErrorCollector errors) + { + // Tolerant of duplicated sources for the same reason as MapReachableKeys. + var map = new Dictionary(StringComparer.Ordinal); + foreach (var alias in aliases) + { + map[alias.Schema] = alias.AliasOf; + } + + var seen = new HashSet(StringComparer.Ordinal); + var canonical = new List(overrides.Count); + foreach (var propertyOverride in overrides) + { + var schema = map.GetValueOrDefault(propertyOverride.Schema, propertyOverride.Schema); + if (!seen.Add($"{schema}\0{propertyOverride.Property}")) + { + errors.Add( + BindingErrorCategory.Curation, + $"{schema}.{propertyOverride.Property}", + "property overrides collapse onto the same property after alias resolution"); + } + + canonical.Add(string.Equals(schema, propertyOverride.Schema, StringComparison.Ordinal) + ? propertyOverride + : propertyOverride with { Schema = schema }); + } + + 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 From e7015df5df3799666e87627c35dcedbd2e5dff38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 13:23:02 +0300 Subject: [PATCH 39/47] fix(tools): name selected component request bodies from their operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operation-derived Request naming applied only to inline bodies, so a selected operation with a component-referenced body would surface the raw component spelling instead of the sealed {Subject}{Verb}Request name (review blocker #12). The name resolver now takes the selected operations and claims every selected nominal body root — inline or component — with the operation-derived name; ownership is selection-scoped so pending operations cannot rename shared components, two selected claims under different names refuse, nested dependencies stay shared, and BindRequestBody asserts the resolved name. Folds #8: operations mixing a request body and query parameters refuse explicitly in the wire-shape check, independent of any name collision, until the merged-Request design seals at M3. --- .../Binding/OperationPlanBinderTests.cs | 66 +++++++++++++++++-- .../Generator/Binding/OperationPlanBinder.cs | 21 ++++++ .../Generator/Binding/SchemaNameResolver.cs | 38 ++++++++--- .../Generator/Binding/SpecBinder.cs | 2 +- 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index 6882d5933..b958877f9 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -478,6 +478,66 @@ await Assert.That(exception.Errors.Any(static error => error.Category == Binding && error.Problem.Contains("envelope DTO", StringComparison.Ordinal))).IsTrue(); } + [Test] + public async Task Bind_Should_Name_A_Component_Request_Body_From_The_Operation() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("Widget.CreatePayload", schema => schema.Type("object") + .Property("title", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => operation + .RequestBody("application/json", body => body.Ref("Widget.CreatePayload"), 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(plan.Models.Any(static model => model.Name == "WidgetCreateRequest")).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "WidgetCreatePayload")).IsFalse(); + await Assert.That(plan.Registry.TypeNames.Contains("WidgetCreateRequest", StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Not_Let_A_Pending_Operation_Rename_A_Shared_Component() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetShared", schema => schema.Type("object") + .Property("note", property => property.Type("string"), required: true)) + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true) + .Property("shared", property => property.Ref("WidgetShared"), required: true)) + .WithOperation("v2.widget.list", path: "/api/widget", configure: operation => operation + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget-create", configure: operation => operation + .RequestBody("application/json", body => body.Ref("WidgetShared"), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + var plan = BindWidgets(document); + + await Assert.That(plan.Models.Any(static model => model.Name == "WidgetShared")).IsTrue(); + await Assert.That(plan.Models.Any(static model => model.Name == "WidgetCreateRequest")).IsFalse(); + } + + [Test] + public async Task Bind_Should_Refuse_An_Operation_Mixing_A_Body_And_Query_Parameters() + { + var document = await BindingTestHost.IngestAsync(SpecScenario.Define(spec => spec + .WithSchema("WidgetInfo", schema => schema.Type("object") + .Property("id", property => property.Type("string"), required: true)) + .WithSchema("Widget.CreatePayload", schema => schema.Type("object") + .Property("title", property => property.Type("string"), required: true)) + .WithOperation("v2.widget.create", method: "post", path: "/api/widget", configure: operation => operation + .Parameter("search", "query", static schema => schema.AnyOf( + static branch => branch.Type("string"), + static branch => branch.Type("null")), required: false) + .RequestBody("application/json", body => body.Ref("Widget.CreatePayload"), required: true) + .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); + + await AssertWidgetRefusalAsync(document, "request body and query", "v2.widget.create"); + } + [Test] public async Task Bind_Should_Bind_A_Component_Data_Envelope_Without_Modeling_The_Wrapper() { @@ -817,11 +877,7 @@ public async Task Bind_Should_Refuse_An_Operation_Declaring_Both_A_Body_And_Quer static branch => branch.Type("null"))), required: true) .Response(200, "application/json", schema => schema.Ref("WidgetInfo"))))); - var exception = Assert.Throws(() => _ = BindWidgets(document, "v2.widget.create")); - - await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Naming - && error.Problem.Contains("WidgetCreateRequest", StringComparison.Ordinal) - && error.Problem.Contains("collides", StringComparison.Ordinal))).IsTrue(); + await AssertWidgetRefusalAsync(document, "request body and query", "v2.widget.create"); } [Test] diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index de2932fb6..d6ebc433f 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -371,6 +371,15 @@ private bool CheckWireShape() Refuse("POST operations must carry a request body"); } + // Refused independently of any name collision: one uniform *Request input cannot + // carry both a body and query yet — admission waits for the sealed merged-Request + // design (M3 opens with it). + 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 await the merged-Request design"); + } + CheckParameterShapes(); CheckStatusShape(); return _errors.Count == before; @@ -810,6 +819,18 @@ private int TemplatePosition(string wireName) return null; } + // The name resolver claims every selected body root with the operation-derived + // request name; a mismatch means the ownership map and this binding disagree. + var expected = OperationNamePolicy.RequestTypeName(_operation); + if (!string.Equals(typeName, expected, StringComparison.Ordinal)) + { + _errors.Add( + BindingErrorCategory.Naming, + reference.Target, + $"request body resolved to '{typeName}' instead of the operation-derived '{expected}'"); + return null; + } + return new RequestBodyPlan { TypeName = typeName, diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs index 1bfcccdf0..36a2b622a 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs @@ -8,14 +8,16 @@ internal sealed class SchemaNameResolver { private readonly StringComparer _comparer = StringComparer.Ordinal; - public IReadOnlyDictionary Resolve(SpecDocument document, ReachableSchemaSet reachable, BindingErrorCollector errors) + public IReadOnlyDictionary Resolve(SpecDocument document, ReachableSchemaSet reachable, + IReadOnlyList selected, BindingErrorCollector errors) { ArgumentNullException.ThrowIfNull(document); ArgumentNullException.ThrowIfNull(reachable); + ArgumentNullException.ThrowIfNull(selected); ArgumentNullException.ThrowIfNull(errors); var responseRoots = reachable.ResponseRootKeys.ToHashSet(_comparer); - var requestRoots = ResolveRequestBodyRootNames(document); + var requestRoots = ResolveRequestBodyRootNames(selected, errors); var result = new Dictionary(_comparer); var owners = new Dictionary(_comparer); foreach (var key in reachable.GraphKeys) @@ -50,20 +52,36 @@ public IReadOnlyDictionary Resolve(SpecDocument document, Reacha private static bool IsNominal(SchemaNode schema) => schema is ObjectNode or EnumNode or UnionNode; /// - /// Inline request bodies are named from their operation identity — the mechanical - /// {Subject}{Verb}Request rule — because their graph keys carry the raw - /// operation id, which public names never surface (ADR-0005 strips the protocol prefix). + /// Every selected operation's nominal body root — inline or component-referenced — is + /// named from its operation identity, the mechanical {Subject}{Verb}Request rule + /// (public names never surface raw operation ids or dotted component spellings). + /// Ownership is scoped to the selection so a pending operation can never rename a + /// component the selected closure shares; two selected operations claiming one root + /// under different names refuse until a per-operation copy design exists. Nested + /// component dependencies stay shared: only the root reference is claimed. /// - private Dictionary ResolveRequestBodyRootNames(SpecDocument document) + private Dictionary ResolveRequestBodyRootNames(IReadOnlyList selected, + BindingErrorCollector errors) { var names = new Dictionary(_comparer); - foreach (var operation in document.Operations) + foreach (var operation in selected) { - if (operation.RequestBody?.Schema is RefNode reference - && reference.Target.StartsWith($"op:{operation.OperationId}#", StringComparison.Ordinal)) + if (operation.RequestBody?.Schema is not RefNode reference) { - names[reference.Target] = OperationNamePolicy.RequestTypeName(operation); + continue; + } + + var requestName = OperationNamePolicy.RequestTypeName(operation); + if (names.TryGetValue(reference.Target, out var existing) && !_comparer.Equals(existing, requestName)) + { + errors.Add( + BindingErrorCategory.Naming, + reference.Target, + $"request body root is claimed as both '{existing}' and '{requestName}' by selected operations"); + continue; } + + names[reference.Target] = requestName; } return names; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs index 7bcc0e607..6ef137d30 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs @@ -49,7 +49,7 @@ public EmitPlan Bind(SpecDocument document, OperationSelection selection, Genera // Type names are resolved exactly once per bind; schema and operation binding // consume the same map so neither can reinterpret a schema under another name. - var typeNames = _schemaNames.Resolve(document, reachable, errors); + var typeNames = _schemaNames.Resolve(document, reachable, selected, errors); var schemaResult = _schemaPlans.Bind(document, reachable, curation, typeNames, errors); var clients = _operationPlans.Bind(document, selected, curation, typeNames, errors); var selectedIds = selected.Select(static operation => operation.OperationId).ToHashSet(StringComparer.Ordinal); From 691efc801078638911bfde9fdf303bd41974975c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 13:31:39 +0300 Subject: [PATCH 40/47] fix(sdk): carrier converters handle null and enforce fixed outer markers Concrete unknown-carrier converters lacked HandleNull, so deserializing a null token bypassed Read and returned null for a non-nullable carrier (review blocker #14); neither converter validated a nested union's fixed outer tag, so a contradicted discriminator rode into the carrier and replayed on serialization (#13, folded). Carriers now emit HandleNull with an explicit null-token rejection, and both the carrier and base converters validate the fixed marker's presence, kind, and exact value while keeping unknown-inner-marker tolerance and raw replay. All eight carriers regenerate; regressions deserialize through the raw type info so the wrapper's null coalesce cannot mask a bypass. --- .../SessionMessageCompactionJsonConverter.cs | 10 + .../UnknownOpenCodeErrorJsonConverter.cs | 7 + .../UnknownPromptFileSourceJsonConverter.cs | 7 + ...UnknownSessionForkBoundaryJsonConverter.cs | 7 + ...ionMessageAssistantContentJsonConverter.cs | 7 + ...wnSessionMessageCompactionJsonConverter.cs | 17 ++ .../UnknownSessionMessageInfoJsonConverter.cs | 7 + ...ownSessionMessageToolStateJsonConverter.cs | 7 + .../UnknownToolContentJsonConverter.cs | 7 + .../mismatched-compaction-marker.json | 1 + .../Serialization/OpenCodeJsonContextTests.cs | 26 +++ .../Generator/Emission/UnionEmitterTests.cs | 11 ++ ...erant_Marked_Union_Converters.verified.txt | 178 ++++++++++++++++++ .../Support/EmitterPlanFixture.cs | 24 ++- .../Generator/Emission/UnionEmitter.cs | 76 +++++++- 15 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Fixtures/Serialization/mismatched-compaction-marker.json diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs index e8af70ce0..f7eb32082 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageCompactionJsonConverter.cs @@ -32,6 +32,16 @@ public override SessionMessageCompaction Read(ref Utf8JsonReader reader, Type ty throw new JsonException("The SessionMessageCompaction payload must be a JSON object."); } + if (!payload.TryGetProperty("type", out var fixedElement)) + { + throw new JsonException("The SessionMessageCompaction payload must contain 'type'."); + } + + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("compaction")) + { + throw new JsonException("The 'type' marker must be 'compaction'."); + } + if (!payload.TryGetProperty("status", out var markerElement)) { throw new JsonException("The SessionMessageCompaction payload must contain 'status'."); diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs index 7fb5d0f52..5be7ef85a 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownOpenCodeErrorJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownOpenCodeErrorJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownOpenCodeError Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The OpenCodeError payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs index 19fb445a8..9539ced9c 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownPromptFileSourceJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownPromptFileSourceJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownPromptFileSource Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The PromptFileSource payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs index ce1d0cbdd..d536ad247 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionForkBoundaryJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownSessionForkBoundaryJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownSessionForkBoundary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionForkBoundary payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs index c2754033b..afba3eaac 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownSessionMessageAssistantContentJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownSessionMessageAssistantContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageAssistantContent payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs index 9e229693b..6b731c157 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownSessionMessageCompactionJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownSessionMessageCompaction Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageCompaction payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) @@ -19,6 +26,16 @@ public override UnknownSessionMessageCompaction Read(ref Utf8JsonReader reader, throw new JsonException("The SessionMessageCompaction payload must be a JSON object."); } + if (!payload.TryGetProperty("type", out var fixedElement)) + { + throw new JsonException("The SessionMessageCompaction payload must contain 'type'."); + } + + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("compaction")) + { + throw new JsonException("The 'type' marker must be 'compaction'."); + } + if (!payload.TryGetProperty("status", out var markerElement)) { throw new JsonException("The SessionMessageCompaction payload must contain 'status'."); diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs index ab475eb0d..2e7b5c9f7 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownSessionMessageInfoJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownSessionMessageInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageInfo payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs index c191af87d..a8ee9805a 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownSessionMessageToolStateJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownSessionMessageToolState Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The SessionMessageToolState payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs index 0ee40bfbb..9f7ec79bf 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/UnknownToolContentJsonConverter.cs @@ -8,10 +8,17 @@ namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownToolContentJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownToolContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException("The ToolContent payload cannot be null."); + } + using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/mismatched-compaction-marker.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/mismatched-compaction-marker.json new file mode 100644 index 000000000..a7fa50e8b --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/mismatched-compaction-marker.json @@ -0,0 +1 @@ +{"details":{"phase":"later"},"status":"paused","type":"user"} diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs index ecd726a3c..f09bd19b1 100644 --- a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using OpenCode.Sdk.Models; using OpenCode.Sdk.Tests.Support; @@ -33,6 +34,31 @@ public async Task Deserialize_Should_Reject_Explicit_Null_On_An_Optional_Nonnull _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); } + [Test] + public async Task Deserialize_Should_Reject_Null_For_A_Concrete_Unknown_Carrier() + { + var typeInfo = (JsonTypeInfo)_serializer.GetTypeInfo(typeof(UnknownOpenCodeError)); + + _ = await Assert.That(() => JsonSerializer.Deserialize("null", typeInfo)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Reject_A_Contradicted_Fixed_Marker_On_The_Concrete_Carrier() + { + var json = _fixtures.LoadJson("Serialization.mismatched-compaction-marker.json"); + var typeInfo = (JsonTypeInfo)_serializer.GetTypeInfo(typeof(UnknownSessionMessageCompaction)); + + _ = await Assert.That(() => JsonSerializer.Deserialize(json, typeInfo)).Throws(); + } + + [Test] + public async Task Deserialize_Should_Reject_A_Contradicted_Fixed_Marker_On_The_Union_Base() + { + var json = _fixtures.LoadJson("Serialization.mismatched-compaction-marker.json"); + + _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); + } + [Test] public async Task Deserialize_Should_Reject_A_Numeric_String_Enum_Value() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs index 3d705aa18..4b774ece8 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs @@ -12,4 +12,15 @@ public async Task Emit_Should_Produce_Tolerant_Marked_Union_Converters() await Verify(EmitterSnapshot.Create(sources)); } + + [Test] + public async Task Emit_Should_Guard_Carrier_Converters_Against_Null_And_Foreign_Fixed_Markers() + { + var source = EmitterSnapshot.Create(UnionEmitter.Emit(EmitterPlanFixture.CreateUnionSnapshot())); + + var carrier = source[source.IndexOf("class UnknownExamplePhaseJsonConverter", StringComparison.Ordinal)..]; + await Assert.That(carrier).Contains("HandleNull => true"); + await Assert.That(carrier).Contains("The ExamplePhase payload cannot be null."); + await Assert.That(carrier).Contains("must be 'phase'"); + } } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt index 8e036e577..95b3f3076 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt @@ -128,10 +128,14 @@ using OpenCode.Sdk.Models; namespace OpenCode.Sdk.Internal.Serialization; internal sealed class UnknownExampleEventJsonConverter : JsonConverter { + public override bool HandleNull => true; + public override UnknownExampleEvent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { ArgumentNullException.ThrowIfNull(typeToConvert); ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + throw new JsonException("The ExampleEvent payload cannot be null."); using var document = JsonDocument.ParseValue(ref reader); var payload = document.RootElement; if (payload.ValueKind != JsonValueKind.Object) @@ -154,3 +158,177 @@ internal sealed class UnknownExampleEventJsonConverter : JsonConverter +/// Represents an example nested phase union. +/// +[JsonConverter(typeof(ExamplePhaseJsonConverter))] +public abstract record ExamplePhase : ExampleEvent +{ + /// + /// Gets the 'status' union marker. + /// + [JsonPropertyName("status")] + public abstract string Status { get; } +} + +// File: Models/UnknownExamplePhase.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.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Preserves an unknown example phase payload. +/// +[JsonConverter(typeof(UnknownExamplePhaseJsonConverter))] +public sealed record UnknownExamplePhase : ExamplePhase +{ + private readonly string _marker; + /// + /// Initializes an unknown union value from its marker and raw payload. + /// + [JsonConstructor] + public UnknownExamplePhase(string status, JsonElement payload) + { + ArgumentException.ThrowIfNullOrEmpty(status); + _marker = status; + Payload = payload.Clone(); + } + + /// + /// Gets the unrecognized 'status' marker. + /// + [JsonPropertyName("status")] + public override string Status => _marker; + + /// + /// Gets the fixed 'type' marker of this nested union. + /// + [JsonPropertyName("type")] + public override string Type => "phase"; + /// + /// Gets the preserved raw JSON payload. + /// + public JsonElement Payload { get; } +} + +// File: Internal/Serialization/ExamplePhaseJsonConverter.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.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +internal sealed class ExamplePhaseJsonConverter : JsonConverter +{ + private static readonly Dictionary TypesByTag = new Dictionary(StringComparer.Ordinal) + { + ["started"] = typeof(PhaseStartedEvent) + }; + public override bool HandleNull => true; + + public override ExamplePhase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + throw new JsonException("The ExamplePhase payload cannot be null."); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + throw new JsonException("The ExamplePhase payload must be a JSON object."); + if (!payload.TryGetProperty("type", out var fixedElement)) + throw new JsonException("The ExamplePhase payload must contain 'type'."); + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("phase")) + throw new JsonException("The 'type' marker must be 'phase'."); + if (!payload.TryGetProperty("status", out var markerElement)) + throw new JsonException("The ExamplePhase payload must contain 'status'."); + if (markerElement.ValueKind != JsonValueKind.String) + throw new JsonException("The 'status' marker must be a string."); + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + throw new JsonException("The 'status' marker must be a non-empty string."); + if (TypesByTag.TryGetValue(marker, out var targetType)) + { + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for ExamplePhase."); + return JsonSerializer.Deserialize(payload, typeInfo) as ExamplePhase ?? throw new JsonException("The ExamplePhase payload deserialized to null."); + } + + return new UnknownExamplePhase(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, ExamplePhase value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + if (value is UnknownExamplePhase unknown) + { + unknown.Payload.WriteTo(writer); + return; + } + + var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for ExamplePhase."); + JsonSerializer.Serialize(writer, value, typeInfo); + } +} + +// File: Internal/Serialization/UnknownExamplePhaseJsonConverter.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.Text.Json; +using System.Text.Json.Serialization; +using OpenCode.Sdk.Models; + +namespace OpenCode.Sdk.Internal.Serialization; +internal sealed class UnknownExamplePhaseJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + public override UnknownExamplePhase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(typeToConvert); + ArgumentNullException.ThrowIfNull(options); + if (reader.TokenType == JsonTokenType.Null) + throw new JsonException("The ExamplePhase payload cannot be null."); + using var document = JsonDocument.ParseValue(ref reader); + var payload = document.RootElement; + if (payload.ValueKind != JsonValueKind.Object) + throw new JsonException("The ExamplePhase payload must be a JSON object."); + if (!payload.TryGetProperty("type", out var fixedElement)) + throw new JsonException("The ExamplePhase payload must contain 'type'."); + if (fixedElement.ValueKind != JsonValueKind.String || !fixedElement.ValueEquals("phase")) + throw new JsonException("The 'type' marker must be 'phase'."); + if (!payload.TryGetProperty("status", out var markerElement)) + throw new JsonException("The ExamplePhase payload must contain 'status'."); + if (markerElement.ValueKind != JsonValueKind.String) + throw new JsonException("The 'status' marker must be a string."); + var marker = markerElement.GetString(); + if (marker is null || string.IsNullOrWhiteSpace(marker)) + throw new JsonException("The 'status' marker must be a non-empty string."); + return new UnknownExamplePhase(marker, payload); + } + + public override void Write(Utf8JsonWriter writer, UnknownExamplePhase value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + value.Payload.WriteTo(writer); + } +} diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 49cafff5c..77b18ea33 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -387,7 +387,29 @@ public static EmitPlan CreateModelSnapshot() }; } - public static IReadOnlyList CreateUnionSnapshot() => [CreateExampleEvent()]; + public static IReadOnlyList CreateUnionSnapshot() => [CreateExampleEvent(), CreateExamplePhase()]; + + /// A nested union whose outer tag is fixed; both converters must enforce it. + private static UnionPlan CreateExamplePhase() => + new() + { + Name = "ExamplePhase", + Namespace = "OpenCode.Sdk.Models", + UnknownTypeName = "UnknownExamplePhase", + MarkerWireName = "status", + MarkerName = "Status", + MarkerKind = LiteralKind.String, + BaseTypeName = "ExampleEvent", + FixedMarker = new UnionFixedMarkerPlan + { + WireName = "type", + Name = "Type", + Kind = LiteralKind.String, + Value = "phase", + }, + Variants = [new UnionVariantPlan { TypeName = "PhaseStartedEvent", Tag = "started", }], + Description = "Represents an example nested phase union.", + }; public static RegistryPlan CreateRegistry() => Create().Registry; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs index dd5927e5b..1a61638de 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs @@ -112,11 +112,13 @@ private static GeneratedSource EmitCarrierConverter(UnionPlan union) readStatements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); readStatements.AddRange( [ + EmitNullTokenCheck(union), EmitPayloadDocument(), Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")), EmitObjectPayloadCheck(union), - EmitMarkerPresenceCheck(union), ]); + readStatements.AddRange(EmitFixedMarkerCheck(union)); + readStatements.Add(EmitMarkerPresenceCheck(union)); readStatements.AddRange(EmitMarkerRead(union)); readStatements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( SyntaxFactory.IdentifierName(union.UnknownTypeName)) @@ -170,7 +172,7 @@ private static GeneratedSource EmitCarrierConverter(UnionPlan union) SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.Generic( "JsonConverter", TypeSyntaxEmitter.EmitNamed(union.UnknownTypeName)))))) - .WithMembers(SyntaxFactory.List([read, write])); + .WithMembers(SyntaxFactory.List([EmitHandleNull(), read, write])); var unit = EmissionSyntax.CompilationUnit( "OpenCode.Sdk.Internal.Serialization", [ @@ -323,17 +325,13 @@ private static MethodDeclarationSyntax EmitRead(UnionPlan union) statements.AddRange(EmissionSyntax.ArgumentNullGuard("options")); statements.AddRange( [ - SyntaxFactory.IfStatement( - SyntaxFactory.BinaryExpression( - SyntaxKind.EqualsExpression, - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("reader"), "TokenType"), - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonTokenType"), "Null")), - ThrowJson($"The {union.Name} payload cannot be null.")), + EmitNullTokenCheck(union), EmitPayloadDocument(), Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")), EmitObjectPayloadCheck(union), - EmitMarkerPresenceCheck(union), ]); + statements.AddRange(EmitFixedMarkerCheck(union)); + statements.Add(EmitMarkerPresenceCheck(union)); statements.AddRange(EmitMarkerRead(union)); statements.Add(EmitKnownDispatch(union)); statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( @@ -359,6 +357,66 @@ private static MethodDeclarationSyntax EmitRead(UnionPlan union) .WithBody(SyntaxFactory.Block(statements)); } + private static IfStatementSyntax EmitNullTokenCheck(UnionPlan union) => + SyntaxFactory.IfStatement( + SyntaxFactory.BinaryExpression( + SyntaxKind.EqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("reader"), "TokenType"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonTokenType"), "Null")), + ThrowJson($"The {union.Name} payload cannot be null.")); + + /// + /// A nested union's fixed outer tag is structural identity, not dispatch input: a + /// foreign value is a malformed payload, while the inner marker keeps its + /// unknown-variant tolerance. + /// + private static IReadOnlyList EmitFixedMarkerCheck(UnionPlan union) + { + if (union.FixedMarker is not { } marker) + { + return []; + } + + var presence = SyntaxFactory.IfStatement( + SyntaxFactory.PrefixUnaryExpression(SyntaxKind.LogicalNotExpression, EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "TryGetProperty"), + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(marker.WireName))), + SyntaxFactory.Argument(SyntaxFactory.DeclarationExpression( + SyntaxFactory.IdentifierName("var"), + SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("fixedElement")))) + .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.OutKeyword)))), + ThrowJson($"The {union.Name} payload must contain '{marker.WireName}'.")); + var value = SyntaxFactory.IfStatement( + EmitFixedMarkerMismatch(union, marker), + ThrowJson($"The '{marker.WireName}' marker must be '{marker.Value}'.")); + return [presence, value]; + } + + private static BinaryExpressionSyntax EmitFixedMarkerMismatch(UnionPlan union, UnionFixedMarkerPlan marker) => marker.Kind switch + { + LiteralKind.String => SyntaxFactory.BinaryExpression( + SyntaxKind.LogicalOrExpression, + SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("fixedElement"), "ValueKind"), + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "String")), + SyntaxFactory.PrefixUnaryExpression(SyntaxKind.LogicalNotExpression, EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("fixedElement"), "ValueEquals"), + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(marker.Value)))))), + LiteralKind.Boolean when bool.TryParse(marker.Value, out var flag) => SyntaxFactory.BinaryExpression( + SyntaxKind.NotEqualsExpression, + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("fixedElement"), "ValueKind"), + EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("JsonValueKind"), + flag ? "True" : "False")), + LiteralKind.Number or _ => throw new InvalidOperationException( + $"Union '{union.Name}' fixes marker '{marker.WireName}' with kind '{marker.Kind}', which has no emission consumer."), + }; + private static LocalDeclarationStatementSyntax EmitPayloadDocument() { var parse = EmissionSyntax.Invocation( From 52c969602e3ba370cd2c1f1cfd89c78a030d5836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 13:40:05 +0300 Subject: [PATCH 41/47] fix(sdk): land the small confirmed review fold-ins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small confirmed findings from the PR #26 review land together: the owned transport disposes when the pipeline constructor refuses credentials after creation (#15); the family-folder shadow and duplicate checks compare with the writer's case-insensitive owned-path comparer (#20); QueryStringBuilder escapes parameter names exactly like values (#21); and the User-Agent product token resolves once into a shared immutable instance instead of re-reflecting per client (#30 — no performance claim made, the reflection removal is the recorded obvious-waste rule). The Username documentation drops its pinned-server claim: the pinned server accepts only 'opencode'; --username/OPENCODE_SERVER_USERNAME is upstream direction, and the option is the gateway seam (#23; doc 15 and the Q89 finding corrected). --- docs/research/00-research-log.md | 6 ++-- docs/research/15-opencode-v2-platform.md | 7 +++-- src/OpenCode.Sdk/IOpenCodeClientOptions.cs | 2 +- src/OpenCode.Sdk/Internal/Pipeline.cs | 13 ++++++++- .../Internal/QueryStringBuilder.cs | 2 +- src/OpenCode.Sdk/Internal/UserAgentPolicy.cs | 9 ++++-- src/OpenCode.Sdk/OpenCodeClientOptions.cs | 8 ++++-- .../QueryStringBuilderTests.cs | 10 +++++++ .../UserAgentPolicyTests.cs | 6 ++++ .../Output/GenerationWriterTests.cs | 28 +++++++++++++++++++ .../Output/GenerationWriter.cs | 8 ++++-- 11 files changed, 83 insertions(+), 16 deletions(-) diff --git a/docs/research/00-research-log.md b/docs/research/00-research-log.md index dccb8418d..45d525b64 100644 --- a/docs/research/00-research-log.md +++ b/docs/research/00-research-log.md @@ -1863,8 +1863,10 @@ collision wall fires. Census and mechanism notes live in doc 15 §5a/§6. **Found:** no URL environment variable exists; the first-party discovery contract is the registration file (doc 15 §6). Auth is optional (`ServerAuth.required` = configured, -non-empty password). The username is configurable (`--username`/`OPENCODE_SERVER_USERNAME`, -default `opencode`) — our pipeline's hardcoded `opencode` user is incomplete. Client-side +non-empty password). The username is `opencode` at the pin (the pinned server hardcodes +it); `--username`/`OPENCODE_SERVER_USERNAME` is upstream direction — docs and the desktop +sidecar — not pinned-server behavior, so the `Username` option is the gateway and +forward-compatibility seam. Client-side password-from-environment resolution lives in the CLI consumer (`OPENCODE_PASSWORD` → legacy `OPENCODE_SERVER_PASSWORD`), not in the client library. Feeds the pending Q86 batch (options shape, env-fallback ownership, `Username` option). diff --git a/docs/research/15-opencode-v2-platform.md b/docs/research/15-opencode-v2-platform.md index 91c008f59..78facee45 100644 --- a/docs/research/15-opencode-v2-platform.md +++ b/docs/research/15-opencode-v2-platform.md @@ -188,9 +188,10 @@ bundled CLI this way). **Auth (re-derived at the pin, 2026-08-14)**: HTTP Basic, and **optional** — the server's `ServerAuth.required` is true only when a password is configured and non-empty; otherwise the authorization middleware is a pass-through (`packages/server/src/middleware/authorization.ts`). -The username is **configurable**: default `opencode`, overridden by `--username`/`-u` on -`serve`/`web` or `OPENCODE_SERVER_USERNAME` (upstream CLI/server docs; the desktop WSL sidecar -exports it). Client-side password resolution lives in the *consumer*, not the client library: +The username is `opencode` at the pin: the pinned server layer hardcodes it, while the +`--username`/`-u` and `OPENCODE_SERVER_USERNAME` controls exist only as upstream direction +(CLI/server docs; the desktop WSL sidecar exports the variable) that has not reached the +pinned server implementation. Client-side password resolution lives in the *consumer*, not the client library: `packages/cli/src/env.ts` resolves `OPENCODE_PASSWORD` falling back to the legacy `OPENCODE_SERVER_PASSWORD` and hands the value to the client — the generated client itself reads no environment. Service mode generates `randomBytes(32).toString("base64url")` when diff --git a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs index ca95de596..c00daad32 100644 --- a/src/OpenCode.Sdk/IOpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/IOpenCodeClientOptions.cs @@ -10,7 +10,7 @@ public interface IOpenCodeClientOptions /// Gets the absolute HTTP or HTTPS server endpoint; required to build a client. public Uri? Endpoint { get; } - /// Gets the HTTP basic-authentication username; the upstream default is opencode. + /// Gets the HTTP basic-authentication username; the pinned server accepts only the default opencode. public string Username { get; } /// Gets the HTTP basic-authentication password; sends anonymous requests. diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index b33df4988..9550ef472 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -87,7 +87,18 @@ public static Pipeline Create(OpenCodeClientOptions options) // Validate before constructing the owned client so a refused endpoint leaks nothing. _ = EndpointPolicy.Normalize(options.Endpoint); - return new Pipeline(CreateOwnedHttpClient(), ownsHttpClient: true, options); + var httpClient = CreateOwnedHttpClient(); + try + { + return new Pipeline(httpClient, ownsHttpClient: true, options); + } + catch + { + // Credential validation still throws inside the constructor; the owned + // transport must not outlive a refused construction. + httpClient.Dispose(); + throw; + } } public static Pipeline Create(HttpClient httpClient, OpenCodeClientOptions options) diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs index b2764cf4c..86c0731b7 100644 --- a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -65,7 +65,7 @@ private void Append(string name, string value) { _builder ??= new StringBuilder(); _ = _builder.Append(_builder.Length is 0 ? '?' : '&') - .Append(name) + .Append(Uri.EscapeDataString(name)) .Append('=') .Append(Uri.EscapeDataString(value)); } diff --git a/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs b/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs index 178906f88..da5166024 100644 --- a/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs +++ b/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs @@ -8,10 +8,13 @@ internal static class UserAgentPolicy { private const string ProductName = "OpenCode.Sdk"; + /// The informational version is process-invariant, so the reflection runs once; + /// the token value object is immutable and safe to share across header collections. + private static readonly ProductInfoHeaderValue Resolved = Compose(typeof(UserAgentPolicy).Assembly + .GetCustomAttribute()?.InformationalVersion); + /// Reads the SDK assembly's informational version into the product token. - public static ProductInfoHeaderValue Resolve() => - Compose(typeof(UserAgentPolicy).Assembly - .GetCustomAttribute()?.InformationalVersion); + public static ProductInfoHeaderValue Resolve() => Resolved; /// /// Composes the product token from an informational version. Build metadata after diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index 2d4d28bdf..e71bf220b 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -11,9 +11,11 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions public Uri? Endpoint { get; set; } /// - /// Gets or sets the HTTP basic-authentication username. The upstream default is - /// opencode; servers started with --username or - /// OPENCODE_SERVER_USERNAME expect the matching value here. + /// Gets or sets the HTTP basic-authentication username. The pinned server accepts + /// only opencode today; upstream documents a --username / + /// OPENCODE_SERVER_USERNAME direction that has not reached the pinned server, + /// so this knob exists for gateways and that upcoming surface — not as a + /// pinned-server control. /// public string Username { get; set; } = "opencode"; diff --git a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs index 755202991..e57ad8e09 100644 --- a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs +++ b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs @@ -43,6 +43,16 @@ public async Task AddText_Should_Escape_The_Value() await Assert.That(query.Value).IsEqualTo("?search=a%20b%26c%3Dd"); } + [Test] + public async Task AddText_Should_Escape_The_Name() + { + var query = new QueryStringBuilder(); + + query.AddText("odd name&x", "v"); + + await Assert.That(query.Value).IsEqualTo("?odd%20name%26x=v"); + } + [Test] public async Task AddCount_Should_Write_The_Invariant_Wire_String() { diff --git a/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs b/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs index 065cac260..78bb2b046 100644 --- a/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs +++ b/tests/OpenCode.Sdk.Tests/UserAgentPolicyTests.cs @@ -29,6 +29,12 @@ public async Task Compose_Should_Omit_The_Version_Token_When_The_Version_Is_Unus await Assert.That(product.ToString()).IsEqualTo("OpenCode.Sdk"); } + [Test] + public async Task Resolve_Should_Cache_The_Product_Token() + { + await Assert.That(UserAgentPolicy.Resolve()).IsSameReferenceAs(UserAgentPolicy.Resolve()); + } + [Test] public async Task Resolve_Should_Read_The_Assembly_Informational_Version() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs index 55392bf8c..931903af3 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs @@ -163,6 +163,34 @@ public async Task WriteAsync_Should_Admit_Root_And_Adapter_Sources() await Assert.That(result.CreatedPaths).Contains("Internal/ResponseAdapters/ExampleAdapter.cs"); } + [Test] + public async Task WriteAsync_Should_Refuse_A_Family_Folder_Shadowing_By_Case() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert + .That(async () => await writer.WriteAsync(Request([], familyFolders: ["models"]), CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("shadows"); + } + + [Test] + public async Task WriteAsync_Should_Refuse_Family_Folders_Duplicated_By_Case() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert + .That(async () => await writer.WriteAsync( + Request([], familyFolders: ["Sessions", "sessions"]), + CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("duplicated"); + } + [Test] public async Task WriteAsync_Should_Admit_Family_Folder_Sources() { diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs index 334575d08..c7d4ee4ba 100644 --- a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs @@ -48,9 +48,13 @@ public async Task WriteAsync(GenerationWriteRequest request, Cancel } /// Admitted family folders are single plain segments that never shadow a statically owned directory. + private static readonly string[] StaticallyOwnedFolders = ["Models", "Internal", "Properties"]; + private static HashSet ValidateFamilyFolders(IReadOnlyList familyFolders) { - var result = new HashSet(StringComparer.Ordinal); + // Owned paths compare case-insensitively (Windows/macOS filesystems), so the + // shadow and duplicate checks must too — 'models' collides with 'Models/' on disk. + var result = new HashSet(OwnedPathComparer); foreach (var folder in familyFolders) { if (string.IsNullOrWhiteSpace(folder) || folder is "." or ".." @@ -59,7 +63,7 @@ private static HashSet ValidateFamilyFolders(IReadOnlyList famil throw new InvalidOperationException($"Family folder '{folder}' is unsafe."); } - if (folder is "Models" or "Internal" or "Properties") + if (StaticallyOwnedFolders.Contains(folder, OwnedPathComparer)) { throw new InvalidOperationException($"Family folder '{folder}' shadows a statically owned directory."); } From 1bbb867c29b44972be470e21b0400d071a03a106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 13:44:24 +0300 Subject: [PATCH 42/47] docs: record the fix-batch landing in the operational state --- docs/ROADMAP.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fb682f3ed..02b3f0e12 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,11 +47,12 @@ landed on the same PR: options-only construction with the read-only `IHttpClientFactory`-based `AddOpenCode` returning the `IHttpClientBuilder`, pooled connection lifetime on the owned transport, and the sandbox as the Generic Host DI showcase. The PR #26 external review ran through adversarial verification (36 findings: -30 confirmed / 4 plausible / 2 refuted): the ten merge blockers plus small fold-ins are -queued as the fix batch in `agents/handover-prompts/HANDOFF-2026-08-14-3.md` — Q91 sealed -2026-08-15 (research log Session 23, doc 16), unblocking blocker #1 — and every surviving -non-blocker lives in issues #27–#30 and the #24 hygiene comment. Merge follows the fix -batch; further breadth batches follow merge. +30 confirmed / 4 plausible / 2 refuted), and the verified fix batch landed 2026-08-15 +red-test-first — all ten merge blockers plus the small confirmed fold-ins, with Q91 sealed +(research log Session 23, doc 16: the caller-owned HttpClient constructor stays public +behind a fail-closed anonymous-mode guard) and 904 tests green. Every surviving +non-blocker lives in issues #27–#30 and the #24 hygiene comment. Merge is awaiting the +maintainer's word; further breadth batches follow merge. ## Milestones From 7ac5bf3abaf29ef693c15822f6e4905c496bff91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 14:03:13 +0300 Subject: [PATCH 43/47] refactor: sweep comments down to the status quo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments in code artifacts explain the status quo on the spot — no doc or ADR citations, no decision dates or attributions, no milestone scoping or future promises, no review-finding ids. A repository-wide sweep rewords or drops every such fragment across the SDK, the generator, the tests, and the build infrastructure, and reunites a doc comment that had drifted away from ReservedPayloadNames. --- .editorconfig | 26 +++++++++---------- Directory.Build.props | 2 +- src/OpenCode.Sdk/Internal/Pipeline.cs | 2 +- .../Internal/QueryStringBuilder.cs | 2 +- src/OpenCode.Sdk/Internal/UserAgentPolicy.cs | 3 +-- src/OpenCode.Sdk/OpenCodeClientOptions.cs | 7 ++--- .../Benchmarks/ClientOperationBenchmarks.cs | 4 +-- .../UnionDeserializationBenchmarks.cs | 3 +-- .../Ingestion/PinnedSpecSmokeTests.cs | 4 +-- .../Generator/Binding/CurationValidator.cs | 2 +- .../Binding/Models/BindingErrorCategory.cs | 2 +- .../Generator/Binding/OperationNamePolicy.cs | 4 +-- .../Generator/Binding/OperationPlanBinder.cs | 17 ++++++------ .../Generator/Binding/SchemaNameResolver.cs | 4 +-- .../Generator/Emission/UnionEmitter.cs | 2 +- 15 files changed, 39 insertions(+), 45 deletions(-) diff --git a/.editorconfig b/.editorconfig index ec7d624e8..53ce7662e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -682,8 +682,8 @@ dotnet_diagnostic.RCS1141.severity = suggestion # SECTION 12: SONARANALYZER (Sxxxx) # ============================================================================= # Policy: only arbitrated rules are listed; everything else rides SonarWay -# defaults, which TreatWarningsAsErrors escalates to error (chosen fail-closed -# posture, 2026-08-08). When a Sonar rule misfires, arbitrate it here with a +# defaults, which TreatWarningsAsErrors escalates to error. When a Sonar rule +# misfires, arbitrate it here with a # comment naming the winner. dotnet_diagnostic.S125.severity = suggestion dotnet_diagnostic.S2094.severity = suggestion @@ -700,7 +700,7 @@ dotnet_diagnostic.S6602.severity = none # SECTION 13: COMPILER DIAGNOSTICS (CSxxxx) # ============================================================================= # Every public member on the shipped surface is documented: generated docs come from the -# spec, hand-written docs by hand (ADR-0004). Tests are exempted in Section 15. +# spec, hand-written docs by hand. Tests are exempted in Section 15. dotnet_diagnostic.CS1591.severity = error # ============================================================================= @@ -735,8 +735,8 @@ dotnet_diagnostic.VSTHRD111.severity = none # ============================================================================= # Placed after every global C# diagnostic section so the file-scoped glob never # narrows a later section. MA0048 (file name = type name) cannot apply to the -# file-based tool entry: the SDK synthesizes the Program type, and the sealed -# entry contract pins the file name (it is the tool name: +# file-based tool entry: the SDK synthesizes the Program type, and the entry +# contract pins the file name (it is the tool name: # ./tools/opencode-tool.cs). Winner: the entry contract; the rule stays error # for every other file, generated output included (emission keeps file = type). [tools/opencode-tool.cs] @@ -785,22 +785,22 @@ dotnet_diagnostic.S2325.severity = none [tools/OpenCode.Sdk.Tools/Generator/Ingestion/Models/LiteralKind.cs] dotnet_diagnostic.CA1720.severity = none -# Generated response adapters gain shared, non-virtual mapping helpers on this -# base at emission build-out, and default interface members cannot carry shared -# behavior on the locked net472/netstandard2.0 targets (CS8701). Winner: the +# The abstract adapter base carries shared, non-virtual mapping behavior for the +# generated response adapters, and default interface members cannot carry shared +# behavior on the net472/netstandard2.0 targets (CS8701). Winner: the # abstract adapter base; S1694 remains enabled for every other file. [src/OpenCode.Sdk/Internal/ResponseAdapter.cs] dotnet_diagnostic.S1694.severity = none -# The generated route surface deliberately nests one static container per operation group -# (maintainer-approved shape, 2026-08-13); flattening would erase the grouping the public +# The generated route surface deliberately nests one static container per operation +# group; flattening would erase the grouping the public # API promises. Winner: the generated route shape; CA1034 remains enabled for every other # file. [src/OpenCode.Sdk/OpenCodeRoutes.cs] dotnet_diagnostic.CA1034.severity = none -# The shipped SDK slices by client family with flat public namespaces (maintainer, -# 2026-08-14): client-family folders are placement, a namespace is API surface, and the +# The shipped SDK slices by client family with flat public namespaces: +# client-family folders are placement, a namespace is API surface, and the # 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. @@ -812,7 +812,7 @@ dotnet_diagnostic.IDE0130.severity = none # DI extension methods live in the Microsoft.Extensions.DependencyInjection namespace so # consumers get AddOpenCode without an extra using - the ecosystem convention every -# companion DI package follows (recorded decision, 2026-08-08). Winner: the ecosystem +# companion DI package follows. Winner: the ecosystem # namespace contract; IDE0130 remains error for every other Extensions file. [src/OpenCode.Sdk.Extensions/OpenCodeServiceCollectionExtensions.cs] dotnet_diagnostic.IDE0130.severity = none diff --git a/Directory.Build.props b/Directory.Build.props index 621f8169a..8a58aec78 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net10.0 netstandard2.0;net472;net8.0;net9.0;$(DefaultTargetFramework) - 14.0 enable diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 9550ef472..07784ac4e 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -167,7 +167,7 @@ private static HttpClient CreateOwnedHttpClient() handler?.Dispose(); } #else - // net472/netstandard2.0 stay on the default handler; ServicePointManager hardening is an M3 item. + // net472/netstandard2.0 stay on the default handler. return new HttpClient(); #endif } diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs index 86c0731b7..6b5e22e73 100644 --- a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -6,7 +6,7 @@ namespace OpenCode.Sdk.Internal; /// /// Composes the query-string suffix of a generated route; unset optionals are omitted and /// every value is escaped. Route builders own the wire conversion rules, so refusals here -/// are the sealed route-boundary contract. +/// are the route-boundary contract. /// internal sealed class QueryStringBuilder { diff --git a/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs b/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs index da5166024..ffa3be1f1 100644 --- a/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs +++ b/src/OpenCode.Sdk/Internal/UserAgentPolicy.cs @@ -19,8 +19,7 @@ internal static class UserAgentPolicy /// /// Composes the product token from an informational version. Build metadata after /// + is stripped; a missing or unparsable version omits the version token - /// entirely — never a silent substitute, never a construction failure - /// (maintainer, 2026-08-13). + /// entirely — never a silent substitute, never a construction failure. /// /// The assembly's informational version, when present. /// The product token for User-Agent decoration. diff --git a/src/OpenCode.Sdk/OpenCodeClientOptions.cs b/src/OpenCode.Sdk/OpenCodeClientOptions.cs index e71bf220b..bc18e6409 100644 --- a/src/OpenCode.Sdk/OpenCodeClientOptions.cs +++ b/src/OpenCode.Sdk/OpenCodeClientOptions.cs @@ -11,11 +11,8 @@ public sealed class OpenCodeClientOptions : IOpenCodeClientOptions public Uri? Endpoint { get; set; } /// - /// Gets or sets the HTTP basic-authentication username. The pinned server accepts - /// only opencode today; upstream documents a --username / - /// OPENCODE_SERVER_USERNAME direction that has not reached the pinned server, - /// so this knob exists for gateways and that upcoming surface — not as a - /// pinned-server control. + /// Gets or sets the HTTP basic-authentication username; the default is + /// opencode, the only username the pinned server accepts. /// public string Username { get; set; } = "opencode"; diff --git a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs index 8fcf2385d..88d7ac719 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/ClientOperationBenchmarks.cs @@ -6,8 +6,8 @@ namespace OpenCode.Sdk.Performance.Tests.Benchmarks; /// /// End-to-end operation cost through the real pipeline over a canned in-memory response: /// request build, auth decoration, buffering, adapter, and the full deserialization chain. -/// Baselines for the envelope single-pass (P2), success-path buffering (P3), and the -/// converter walk they feed (P1); issues #18 and #23. +/// Baselines envelope deserialization, success-path buffering, and the converter walk +/// they feed. /// [MemoryDiagnoser] public class ClientOperationBenchmarks : IDisposable diff --git a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs index 2221606d3..a3974dc27 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/UnionDeserializationBenchmarks.cs @@ -8,8 +8,7 @@ namespace OpenCode.Sdk.Performance.Tests.Benchmarks; /// /// The nested union converter walk in isolation (message → assistant content → tool state → -/// tool content), through the same source-generated dispatch the SDK uses. Baseline for the -/// single-pass converter redesign (issue #23, P1/P5). +/// tool content), through the same source-generated dispatch the SDK uses. /// [MemoryDiagnoser] public class UnionDeserializationBenchmarks diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs index 93263a472..4b683c5b4 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Ingestion/PinnedSpecSmokeTests.cs @@ -6,8 +6,8 @@ namespace OpenCode.Sdk.Tools.Tests.Generator.Ingestion; /// /// The structural gate every future spec refresh runs through: representative landmarks at -/// the known lossy seams, no count assertions. A red landmark is evidence about the wire — -/// stop and classify per the deviation protocol, never patch the assertion. +/// the known lossy seams, no count assertions. A red landmark is evidence about the wire, +/// never a reason to patch the assertion. /// public sealed class PinnedSpecSmokeTests { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs index cd4a26b48..cf0ca3362 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs @@ -155,7 +155,7 @@ private static void ValidateHandleParameterCoverage(string wireName, GroupCurati /// /// Payload names derive mechanically from the operation subject; curated entries are - /// overrides only (maintainer, 2026-08-13), so validation covers orphans and identifier + /// overrides only, so validation covers orphans and identifier /// legality, never presence. /// private static void ValidateEnvelopeNames(HashSet selectedIds, HashSet documentIds, diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs index 18f7533c8..46294d496 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs @@ -15,6 +15,6 @@ public enum BindingErrorCategory /// A selected wire name cannot be mapped to a unique C# identifier. Naming = 3, - /// A selected operation's wire contract cannot be bound by the M1 operation binder. + /// A selected operation's wire contract cannot be bound by the operation binder. Operation = 4, } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs index 07107fdc3..a7312a27b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationNamePolicy.cs @@ -8,7 +8,7 @@ namespace OpenCode.Sdk.Tools.Generator.Binding; /// Verb detection is structural — only the final segment can be a verb — so mid-position /// segments that spell a verb stay in the subject. Derivations that need the pluralized /// group return when the naive rule is unsafe; the binder refuses -/// such operations until the rule set grows. +/// such operations. /// internal static class OperationNamePolicy { @@ -106,7 +106,7 @@ private static string Subject(SpecOperation operation) /// /// Naive pluralization only: words needing -es or -ies return - /// and the operation refuses until the rule set grows deliberately. + /// and the operation refuses. /// private static string? Pluralize(string word) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs index d6ebc433f..4393f417c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationPlanBinder.cs @@ -10,11 +10,6 @@ internal sealed class OperationPlanBinder private const string ClientNamespace = "OpenCode.Sdk"; private const string RootClientName = "OpenCodeClient"; - /// - /// Member names every generated response envelope inherits from the response spine or - /// from /record synthesis; a payload landing on one of them needs a - /// curated override. - /// /// Hand-written public spine types in the client namespace; a generated twin would not compile. private static readonly string[] ReservedSpineTypeNames = [ @@ -41,6 +36,11 @@ internal sealed class OperationPlanBinder "requestOptions", ]; + /// + /// Member names every generated response envelope inherits from the response spine or + /// from /record synthesis; a payload landing on one of them needs a + /// curated override. + /// private static readonly string[] ReservedPayloadNames = [ "Cursor", @@ -371,13 +371,12 @@ private bool CheckWireShape() Refuse("POST operations must carry a request body"); } - // Refused independently of any name collision: one uniform *Request input cannot - // carry both a body and query yet — admission waits for the sealed merged-Request - // design (M3 opens with it). + // 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 await the merged-Request design"); + Refuse("operations mixing a request body and query parameters are not supported"); } CheckParameterShapes(); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs index 36a2b622a..524476459 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs @@ -57,8 +57,8 @@ public IReadOnlyDictionary Resolve(SpecDocument document, Reacha /// (public names never surface raw operation ids or dotted component spellings). /// Ownership is scoped to the selection so a pending operation can never rename a /// component the selected closure shares; two selected operations claiming one root - /// under different names refuse until a per-operation copy design exists. Nested - /// component dependencies stay shared: only the root reference is claimed. + /// under different names refuse. Nested component dependencies stay shared: only the + /// root reference is claimed. /// private Dictionary ResolveRequestBodyRootNames(IReadOnlyList selected, BindingErrorCollector errors) diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs index 1a61638de..712690cde 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs @@ -472,7 +472,7 @@ private static IReadOnlyList EmitStringMarkerRead(UnionPlan uni Local("marker", EmissionSyntax.Invocation( EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "GetString"))), // An empty or whitespace marker is malformed input, classified before any dispatch so the - // unknown-variant carrier's own guard can never surface as a BCL escape (ADR-0009). The + // unknown-variant carrier's own guard can never surface as a BCL escape. The // explicit null disjunct carries the non-null flow fact on TFMs whose BCL lacks the // IsNullOrWhiteSpace annotation. SyntaxFactory.IfStatement( From 03c0aa1d6e0ffdf6c044b3667e1141b0a239f282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 15:20:22 +0300 Subject: [PATCH 44/47] fix(sdk): route timeouts onto the transport spine and refuse conflicting BaseAddress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-review runtime findings: an HttpClient timeout escaped as TaskCanceledException masquerading as caller cancellation — cancellation whose token the caller never canceled now wraps into OpenCodeTransportException on both the send and body-read paths, while genuine token cancellation still passes through untouched. An injected client's BaseAddress, which the pipeline never consults, now refuses at construction and before every send instead of being silently ignored — the same loud posture as the anonymous Authorization guard. The non-positive Limit refusal becomes ArgumentOutOfRangeException carrying the route builder's own 'request' parameter name instead of a helper-internal one. --- src/OpenCode.Sdk/Internal/Pipeline.cs | 34 +++++++++- .../Internal/QueryStringBuilder.cs | 6 +- ...penCodeServiceCollectionExtensionsTests.cs | 13 ++++ tests/OpenCode.Sdk.Tests/PipelineTests.cs | 68 ++++++++++++++++++- .../QueryStringBuilderTests.cs | 7 +- .../Support/TimeoutContent.cs | 16 +++++ 6 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 tests/OpenCode.Sdk.Tests/Support/TimeoutContent.cs diff --git a/src/OpenCode.Sdk/Internal/Pipeline.cs b/src/OpenCode.Sdk/Internal/Pipeline.cs index 07784ac4e..a4cbbfedc 100644 --- a/src/OpenCode.Sdk/Internal/Pipeline.cs +++ b/src/OpenCode.Sdk/Internal/Pipeline.cs @@ -47,6 +47,17 @@ internal Pipeline(HttpClient httpClient, bool ownsHttpClient, IOpenCodeClientOpt nameof(options)); } + // The pipeline builds every request URI absolute from Endpoint, so a BaseAddress on + // an injected client would never be consulted; refuse the conflict instead of + // silently ignoring half the caller's configuration. + if (!ownsHttpClient && httpClient.BaseAddress is not null) + { + throw new ArgumentException( + "The supplied HttpClient carries a BaseAddress, which this SDK never consults — requests are " + + "built absolute from OpenCodeClientOptions.Endpoint. Remove the BaseAddress or change the Endpoint.", + nameof(httpClient)); + } + // Anonymous mode cannot be expressed by omission over an injected client: HttpClient // copies its default headers onto every request that lacks the header, so a default // Authorization would ride requests this SDK promises are anonymous. Refuse the @@ -202,6 +213,13 @@ private async Task ExecuteCoreAsync(HttpMethod meth "was refused before sending. Remove the default header or set a password."); } + if (!_ownsHttpClient && _httpClient.BaseAddress is not null) + { + throw new OpenCodeTransportException( + "The injected HttpClient's BaseAddress is set, and this SDK never consults it — requests are " + + "built absolute from the configured Endpoint. The request was refused before sending."); + } + int status; string rawBody; using (var request = new HttpRequestMessage(method, new Uri(_endpointBase + route, UriKind.Absolute))) @@ -252,9 +270,15 @@ private async Task SendCoreAsync(HttpRequestMessage request catch (Exception exception) when (exception is HttpRequestException or ObjectDisposedException) { // ObjectDisposedException covers a dispose-during-send race; the pre-send disposed - // guard stays outside this catch. OperationCanceledException passes through untouched. + // guard stays outside this catch. throw new OpenCodeTransportException("The opencode server could not be reached.", exception); } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + // The caller's token is untouched, so this cancellation is the transport timing + // out; genuine caller cancellation passes through in the token-canceled case. + throw new OpenCodeTransportException("The opencode server did not respond within the transport timeout.", exception); + } } private static async Task ReadBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken) @@ -269,7 +293,13 @@ private static async Task ReadBodyAsync(HttpResponseMessage response, Ca when (exception is HttpRequestException or IOException or ObjectDisposedException or InvalidOperationException) { // InvalidOperationException covers an unusable response charset surfaced by - // ReadAsStringAsync. OperationCanceledException deliberately passes through untouched. + // ReadAsStringAsync. + throw new OpenCodeTransportException("The opencode response body could not be read.", exception); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + // The caller's token is untouched, so this cancellation is the transport timing + // out mid-body; genuine caller cancellation passes through untouched. throw new OpenCodeTransportException("The opencode response body could not be read.", exception); } } diff --git a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs index 6b5e22e73..1ed2e4a02 100644 --- a/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs +++ b/src/OpenCode.Sdk/Internal/QueryStringBuilder.cs @@ -22,7 +22,7 @@ public void AddText(string name, string? value) } } - public void AddCount(string name, int? value) + public void AddCount(string name, int? value, string parameterName) { if (value is null) { @@ -31,7 +31,9 @@ public void AddCount(string name, int? value) if (value <= 0) { - throw new ArgumentException($"The '{name}' query value must be positive.", nameof(value)); + // The refusal surfaces from a generated route builder, so the builder passes + // the parameter name its own caller can actually see. + throw new ArgumentOutOfRangeException(parameterName, value, $"The '{name}' query value must be positive."); } Append(name, value.Value.ToString(CultureInfo.InvariantCulture)); diff --git a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs index 9289c0659..12a6a889a 100644 --- a/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs +++ b/tests/OpenCode.Sdk.Extensions.Tests/OpenCodeServiceCollectionExtensionsTests.cs @@ -120,6 +120,19 @@ public async Task AddOpenCode_Should_Refuse_An_Anonymous_Factory_Client_Carrying await Assert.That(exception.Message).Contains("Authorization"); } + [Test] + public async Task AddOpenCode_Should_Refuse_A_Factory_Client_Carrying_A_BaseAddress() + { + var services = new ServiceCollection(); + _ = services.AddOpenCode(options => options.Endpoint = Endpoint) + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("http://localhost:9")); + using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => _ = provider.GetRequiredService()); + + await Assert.That(exception.Message).Contains("BaseAddress"); + } + [Test] public async Task AddOpenCode_Should_Refuse_A_Null_Service_Collection() { diff --git a/tests/OpenCode.Sdk.Tests/PipelineTests.cs b/tests/OpenCode.Sdk.Tests/PipelineTests.cs index be454dbf5..7b70f38c7 100644 --- a/tests/OpenCode.Sdk.Tests/PipelineTests.cs +++ b/tests/OpenCode.Sdk.Tests/PipelineTests.cs @@ -437,16 +437,80 @@ public async Task ExecuteAsync_Should_Refuse_An_Undefined_Error_Behavior_Before_ [Test] public async Task ExecuteAsync_Should_Pass_Cancellation_Through() { - using var handler = new RecordingHttpHandler(static _ => throw new OperationCanceledException()); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + using var handler = new RecordingHttpHandler(_ => throw new OperationCanceledException(cancellation.Token)); using var httpClient = new HttpClient(handler); using var pipeline = CreatePipeline(httpClient); _ = await Assert .That(async () => _ = await pipeline.ExecuteAsync( - HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, cancellation.Token)) .Throws(); } + [Test] + public async Task ExecuteAsync_Should_Wrap_A_Timeout_Cancellation_As_A_Transport_Failure() + { + using var handler = new RecordingHttpHandler(static _ => throw new TaskCanceledException()); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.InnerException).IsTypeOf(); + } + + [Test] + public async Task ExecuteAsync_Should_Wrap_A_Timeout_During_The_Body_Read_As_A_Transport_Failure() + { + using var handler = new RecordingHttpHandler(static _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new TimeoutContent(), + }); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.InnerException).IsTypeOf(); + } + + [Test] + public async Task Pipeline_Should_Refuse_An_Injected_Client_Carrying_A_BaseAddress() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + httpClient.BaseAddress = new Uri("http://other:1"); + + var exception = Assert.Throws(() => _ = CreatePipeline(httpClient)); + + await Assert.That(exception.Message).Contains("BaseAddress"); + } + + [Test] + public async Task ExecuteAsync_Should_Refuse_A_BaseAddress_Set_After_Construction() + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var pipeline = CreatePipeline(httpClient); + httpClient.BaseAddress = new Uri("http://other:1"); + + var exception = await Assert + .That(async () => _ = await pipeline.ExecuteAsync( + HttpMethod.Get, "/api/health", new RecordingResponseAdapter(), options: null, CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("BaseAddress"); + await Assert.That(handler.Requests).IsEmpty(); + } + [Test] public async Task Dispose_Should_Dispose_The_Owned_Client() { diff --git a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs index e57ad8e09..734b59687 100644 --- a/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs +++ b/tests/OpenCode.Sdk.Tests/QueryStringBuilderTests.cs @@ -58,7 +58,7 @@ public async Task AddCount_Should_Write_The_Invariant_Wire_String() { var query = new QueryStringBuilder(); - query.AddCount("limit", 50); + query.AddCount("limit", 50, "request"); await Assert.That(query.Value).IsEqualTo("?limit=50"); } @@ -70,8 +70,9 @@ public async Task AddCount_Should_Refuse_A_Non_Positive_Value(int limit) { var query = new QueryStringBuilder(); - var exception = Assert.Throws(() => query.AddCount("limit", limit)); + var exception = Assert.Throws(() => query.AddCount("limit", limit, "request")); + await Assert.That(exception.ParamName).IsEqualTo("request"); await Assert.That(exception.Message).Contains("positive"); } @@ -112,7 +113,7 @@ public async Task Add_Should_Skip_Every_Null_Optional() { var query = new QueryStringBuilder(); - query.AddCount("limit", null); + query.AddCount("limit", null, "request"); query.AddOrder("order", null); query.AddParentFilter("parentID", null); diff --git a/tests/OpenCode.Sdk.Tests/Support/TimeoutContent.cs b/tests/OpenCode.Sdk.Tests/Support/TimeoutContent.cs new file mode 100644 index 000000000..d153be81f --- /dev/null +++ b/tests/OpenCode.Sdk.Tests/Support/TimeoutContent.cs @@ -0,0 +1,16 @@ +using System.Net; + +namespace OpenCode.Sdk.Tests.Support; + +/// Simulates the transport timing out while the response body streams. +internal sealed class TimeoutContent : HttpContent +{ + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + throw new TaskCanceledException(); + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } +} From b628c0f5e5e3324c08e065e7f3ec3b0e6db1e8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 15:20:22 +0300 Subject: [PATCH 45/47] fix(tools): harden guard emission from the second review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handle factory and handle constructor now emit the same route-value guard as the route builders — non-whitespace and never a dot segment — so an id the route would refuse can no longer construct a handle and detonate later under a foreign parameter name; the guard pair lives once in EmissionSyntax. Unknown-carrier constructors refuse an unparsed default(JsonElement) payload with an argument exception instead of letting Clone surface a bare InvalidOperationException. The family-folder shadow wall now also owns Pagination, the hand-written spine folder its own doc comment promised to protect. --- .../Models/UnknownOpenCodeError.cs | 5 +++ .../Models/UnknownPromptFileSource.cs | 5 +++ .../Models/UnknownSessionForkBoundary.cs | 5 +++ .../UnknownSessionMessageAssistantContent.cs | 5 +++ .../Models/UnknownSessionMessageCompaction.cs | 5 +++ .../Models/UnknownSessionMessageInfo.cs | 5 +++ .../Models/UnknownSessionMessageToolState.cs | 5 +++ src/OpenCode.Sdk/Models/UnknownToolContent.cs | 5 +++ src/OpenCode.Sdk/OpenCodeRoutes.cs | 4 +- src/OpenCode.Sdk/Sessions/SessionClient.cs | 7 ++- src/OpenCode.Sdk/Sessions/SessionsClient.cs | 7 ++- .../Serialization/OpenCodeJsonContextTests.cs | 8 ++++ .../Sessions/SessionsClientContractTests.cs | 20 ++++++++- .../Output/GenerationWriterTests.cs | 13 ++++++ ...ce_Virtual_Delegating_Clients.verified.txt | 14 +++++- ...roduce_Escaped_Route_Builders.verified.txt | 4 +- ...erant_Marked_Union_Converters.verified.txt | 10 +++++ .../Generator/Emission/ClientEmitter.cs | 4 +- .../Generator/Emission/EmissionSyntax.cs | 37 +++++++++++++++ .../Generator/Emission/RoutesEmitter.cs | 45 +++++++------------ .../Generator/Emission/UnionEmitter.cs | 20 +++++++++ .../Output/GenerationWriter.cs | 6 +-- 22 files changed, 195 insertions(+), 44 deletions(-) diff --git a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs index b739f1e21..6824e247e 100644 --- a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs +++ b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs @@ -19,6 +19,11 @@ public sealed record UnknownOpenCodeError : OpenCodeError public UnknownOpenCodeError(string tag, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(tag); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = tag; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs b/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs index 507a0e233..806acf7cf 100644 --- a/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs +++ b/src/OpenCode.Sdk/Models/UnknownPromptFileSource.cs @@ -19,6 +19,11 @@ public sealed record UnknownPromptFileSource : PromptFileSource public UnknownPromptFileSource(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs index a4edc0052..1514201e2 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionForkBoundary.cs @@ -19,6 +19,11 @@ public sealed record UnknownSessionForkBoundary : SessionForkBoundary public UnknownSessionForkBoundary(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs index daa807e12..becd43fb7 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs @@ -19,6 +19,11 @@ public sealed record UnknownSessionMessageAssistantContent : SessionMessageAssis public UnknownSessionMessageAssistantContent(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs index 5b2882ca8..2d0b547ff 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageCompaction.cs @@ -19,6 +19,11 @@ public sealed record UnknownSessionMessageCompaction : SessionMessageCompaction public UnknownSessionMessageCompaction(string status, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(status); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = status; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs index c73b6ee2c..fec16b440 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageInfo.cs @@ -19,6 +19,11 @@ public sealed record UnknownSessionMessageInfo : SessionMessageInfo public UnknownSessionMessageInfo(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs index 73772f8d7..fc5cda486 100644 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs +++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs @@ -19,6 +19,11 @@ public sealed record UnknownSessionMessageToolState : SessionMessageToolState public UnknownSessionMessageToolState(string status, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(status); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = status; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/Models/UnknownToolContent.cs b/src/OpenCode.Sdk/Models/UnknownToolContent.cs index c6f459c41..e3dd9ab5e 100644 --- a/src/OpenCode.Sdk/Models/UnknownToolContent.cs +++ b/src/OpenCode.Sdk/Models/UnknownToolContent.cs @@ -19,6 +19,11 @@ public sealed record UnknownToolContent : ToolContent public UnknownToolContent(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index 1c4a4df16..f75d61773 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -100,7 +100,7 @@ public static string ListMessages(string sessionId, MessageListRequest? request } var query = new QueryStringBuilder(); - query.AddCount("limit", request.Limit); + query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); query.AddText("cursor", request.Cursor); return path + query.Value; @@ -125,7 +125,7 @@ public static string ListSessions(SessionListRequest? request = null) var query = new QueryStringBuilder(); query.AddText("workspace", request.Workspace); - query.AddCount("limit", request.Limit); + query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); query.AddText("search", request.Search); query.AddParentFilter("parentID", request.ParentId); diff --git a/src/OpenCode.Sdk/Sessions/SessionClient.cs b/src/OpenCode.Sdk/Sessions/SessionClient.cs index c06b4e737..4c3fbf8ef 100644 --- a/src/OpenCode.Sdk/Sessions/SessionClient.cs +++ b/src/OpenCode.Sdk/Sessions/SessionClient.cs @@ -14,7 +14,12 @@ public class SessionClient internal SessionClient(Pipeline pipeline, string sessionId) { ArgumentNullException.ThrowIfNull(pipeline); - ArgumentException.ThrowIfNullOrEmpty(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + _pipeline = pipeline; _sessionId = sessionId; } diff --git a/src/OpenCode.Sdk/Sessions/SessionsClient.cs b/src/OpenCode.Sdk/Sessions/SessionsClient.cs index d2f0cd19b..110ee4ddf 100644 --- a/src/OpenCode.Sdk/Sessions/SessionsClient.cs +++ b/src/OpenCode.Sdk/Sessions/SessionsClient.cs @@ -32,7 +32,12 @@ protected SessionsClient() /// The bound 'SessionClient'. public virtual SessionClient GetSessionClient(string sessionId) { - ArgumentException.ThrowIfNullOrEmpty(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (sessionId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); + } + return new SessionClient(Pipeline, sessionId); } diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs index f09bd19b1..836bf4f31 100644 --- a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs +++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs @@ -34,6 +34,14 @@ public async Task Deserialize_Should_Reject_Explicit_Null_On_An_Optional_Nonnull _ = await Assert.That(() => _serializer.Deserialize(json)).Throws(); } + [Test] + public async Task Carrier_Constructor_Should_Refuse_An_Unparsed_Payload() + { + var exception = Assert.Throws(() => _ = new UnknownOpenCodeError("future", default)); + + await Assert.That(exception.ParamName).IsEqualTo("payload"); + } + [Test] public async Task Deserialize_Should_Reject_Null_For_A_Concrete_Unknown_Carrier() { diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs index 548cd91b5..53d70e265 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs @@ -50,6 +50,21 @@ await Assert.That(handler.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( "http://localhost:4096/api/session?limit=5&order=desc&search=a%20b&parentID=null&cursor=cur_1"); } + [Test] + [Arguments(" ")] + [Arguments(".")] + [Arguments("..")] + public async Task GetSessionClient_Should_Refuse_An_Id_The_Route_Would_Refuse(string sessionId) + { + using var handler = new RecordingHttpHandler(); + using var httpClient = new HttpClient(handler); + using var client = CreateClient(httpClient); + + var exception = Assert.Throws(() => _ = client.Sessions.GetSessionClient(sessionId)); + + await Assert.That(exception.ParamName).IsEqualTo("sessionId"); + } + [Test] public async Task ListSessionsAsync_Should_Treat_A_Null_Page_Element_As_A_Protocol_Failure() { @@ -85,9 +100,10 @@ public async Task ListSessionsAsync_Should_Refuse_A_Non_Positive_Limit_Before_Se using var httpClient = new HttpClient(handler); using var client = CreateClient(httpClient); - _ = await Assert + var exception = await Assert .That(async () => _ = await client.Sessions.ListSessionsAsync(new SessionListRequest { Limit = 0 })) - .Throws(); + .Throws(); + await Assert.That(exception!.ParamName).IsEqualTo("request"); await Assert.That(handler.Requests).IsEmpty(); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs index 931903af3..107b9b151 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs @@ -176,6 +176,19 @@ public async Task WriteAsync_Should_Refuse_A_Family_Folder_Shadowing_By_Case() await Assert.That(exception!.Message).Contains("shadows"); } + [Test] + public async Task WriteAsync_Should_Refuse_A_Family_Folder_Shadowing_The_Pagination_Spine() + { + var fileSystem = GenerationTestData.CreateFileSystem(); + var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem)); + + var exception = await Assert + .That(async () => await writer.WriteAsync(Request([], familyFolders: ["Pagination"]), CancellationToken.None)) + .Throws(); + + await Assert.That(exception!.Message).Contains("shadows"); + } + [Test] public async Task WriteAsync_Should_Refuse_Family_Folders_Duplicated_By_Case() { 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 49ef1990d..208fb047d 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 @@ -110,7 +110,12 @@ public class WidgetClient internal WidgetClient(Pipeline pipeline, string widgetId) { ArgumentNullException.ThrowIfNull(pipeline); - ArgumentException.ThrowIfNullOrEmpty(widgetId); + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + _pipeline = pipeline; _widgetId = widgetId; } @@ -194,7 +199,12 @@ public class WidgetsClient /// The bound 'WidgetClient'. public virtual WidgetClient GetWidgetClient(string widgetId) { - ArgumentException.ThrowIfNullOrEmpty(widgetId); + ArgumentException.ThrowIfNullOrWhiteSpace(widgetId); + if (widgetId is "." or "..") + { + throw new ArgumentException("Route values must not be dot segments.", nameof(widgetId)); + } + return new WidgetClient(Pipeline, widgetId); } 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 4dcb692c1..6245a73e2 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 @@ -86,7 +86,7 @@ public static class OpenCodeRoutes } var query = new QueryStringBuilder(); - query.AddCount("limit", request.Limit); + query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); query.AddText("cursor", request.Cursor); return path + query.Value; @@ -110,7 +110,7 @@ public static class OpenCodeRoutes } var query = new QueryStringBuilder(); - query.AddCount("limit", request.Limit); + query.AddCount("limit", request.Limit, nameof(request)); query.AddOrder("order", request.Order); query.AddText("cursor", request.Cursor); query.AddParentFilter("parentID", request.ParentId); diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt index 95b3f3076..ee9baa329 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt @@ -41,6 +41,11 @@ public sealed record UnknownExampleEvent : ExampleEvent public UnknownExampleEvent(string type, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(type); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = type; Payload = payload.Clone(); } @@ -202,6 +207,11 @@ public sealed record UnknownExamplePhase : ExamplePhase public UnknownExamplePhase(string status, JsonElement payload) { ArgumentException.ThrowIfNullOrEmpty(status); + if (payload.ValueKind is JsonValueKind.Undefined) + { + throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); + } + _marker = status; Payload = payload.Clone(); } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs index 259b089bd..a2b8d8fcc 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ClientEmitter.cs @@ -196,7 +196,7 @@ private static IEnumerable EmitHandleConstructor(Client ?? throw new InvalidOperationException($"Handle client '{client.Name}' carries no handle parameter."); var statements = new List(); statements.AddRange(EmissionSyntax.ArgumentNullGuard("pipeline")); - statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(parameter.Name)); + statements.AddRange(EmissionSyntax.RouteValueGuard(parameter.Name)); statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName("_pipeline"), @@ -270,7 +270,7 @@ private static PropertyDeclarationSyntax EmitSubClientProperty(ClientPlan client private static MethodDeclarationSyntax EmitHandleFactory(ClientPlan client, HandleFactoryPlan factory) { var statements = new List(); - statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(factory.Parameter.Name)); + statements.AddRange(EmissionSyntax.RouteValueGuard(factory.Parameter.Name)); statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression( TypeSyntaxEmitter.EmitNamed(factory.HandleTypeName)) .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs index 493f54eea..3fd813176 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs @@ -156,6 +156,43 @@ public static IReadOnlyList ArgumentNullOrEmptyGuard(string par return Array.AsReadOnly([guard]); } + /// + /// The guard pair every route value passes — non-whitespace and never a dot segment — + /// emitted identically wherever a route value enters, so acceptance at one boundary + /// can never outrun refusal at another. + /// + public static IReadOnlyList RouteValueGuard(string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parameterName); + var parameter = SyntaxFactory.IdentifierName(parameterName); + var whitespaceGuard = SyntaxFactory.ExpressionStatement(Invocation( + MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrWhiteSpace"), + SyntaxFactory.Argument(parameter))); + var dotSegmentGuard = SyntaxFactory.IfStatement( + SyntaxFactory.IsPatternExpression( + parameter, + SyntaxFactory.BinaryPattern( + SyntaxKind.OrPattern, + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("."))), + SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(".."))))), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName("ArgumentException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("Route values must not be dot segments."))), + SyntaxFactory.Argument(Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(parameter))), + ])))))); + return Array.AsReadOnly([whitespaceGuard, dotSegmentGuard]); + } + private static string NormalizeDocumentation(string value) { var result = new StringBuilder(value.Length); diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs index 2313f19d0..d69a31b97 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RoutesEmitter.cs @@ -84,7 +84,7 @@ private static MethodDeclarationSyntax EmitBuilder(OperationPlan operation) var statements = new List(); foreach (var parameter in operation.Parameters) { - statements.AddRange(EmitRouteValueGuard(parameter.Name)); + statements.AddRange(EmissionSyntax.RouteValueGuard(parameter.Name)); } if (operation.QueryRequest is null) @@ -155,12 +155,24 @@ private static IEnumerable EmitQueryComposition(OperationPlan o .WithArgumentList(SyntaxFactory.ArgumentList())))))); foreach (var property in operation.QueryRequest!.Properties) { - yield return SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), QueryAddMethod(property.Kind)), + var arguments = new List + { SyntaxFactory.Argument(StringLiteral(property.WireName)), SyntaxFactory.Argument(EmissionSyntax.MemberAccess( SyntaxFactory.IdentifierName("request"), - property.PropertyName)))); + property.PropertyName)), + }; + if (property.Kind is QueryValueKind.PositiveCount) + { + // The count guard reports the builder's own parameter, not the helper's. + arguments.Add(SyntaxFactory.Argument(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("request"))))); + } + + yield return SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("query"), QueryAddMethod(property.Kind)), + [.. arguments])); } yield return SyntaxFactory.ReturnStatement(SyntaxFactory.BinaryExpression( @@ -182,31 +194,6 @@ private static IEnumerable EmitQueryComposition(OperationPlan o /// Guards one route value: null/empty/whitespace is refused, and so are the dot segments /// Uri canonicalization would silently rewrite into a different request path. /// - private static StatementSyntax[] EmitRouteValueGuard(string parameterName) - { - var parameter = SyntaxFactory.IdentifierName(parameterName); - var whitespaceGuard = SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation( - EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrWhiteSpace"), - SyntaxFactory.Argument(parameter))); - var dotSegmentGuard = SyntaxFactory.IfStatement( - SyntaxFactory.IsPatternExpression( - parameter, - SyntaxFactory.BinaryPattern( - SyntaxKind.OrPattern, - SyntaxFactory.ConstantPattern(StringLiteral(".")), - SyntaxFactory.ConstantPattern(StringLiteral("..")))), - SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( - SyntaxFactory.IdentifierName("ArgumentException")) - .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( - [ - SyntaxFactory.Argument(StringLiteral("Route values must not be dot segments.")), - SyntaxFactory.Argument(EmissionSyntax.Invocation( - SyntaxFactory.IdentifierName("nameof"), - SyntaxFactory.Argument(parameter))), - ])))))); - return [whitespaceGuard, dotSegmentGuard]; - } - /// Folds the template into literal + escaped-parameter concatenation, in template order. private static ExpressionSyntax EmitConcatenation(OperationPlan operation) { diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs index 712690cde..c6ef4616f 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs @@ -201,6 +201,26 @@ private static ConstructorDeclarationSyntax EmitUnknownConstructor(UnionPlan uni statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(markerParameterName)); } + // default(JsonElement) has no backing document; Clone would surface a bare + // InvalidOperationException instead of an argument refusal. + statements.Add(SyntaxFactory.IfStatement( + SyntaxFactory.IsPatternExpression( + EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "ValueKind"), + SyntaxFactory.ConstantPattern(EmissionSyntax.MemberAccess( + SyntaxFactory.IdentifierName("JsonValueKind"), + "Undefined"))), + SyntaxFactory.Block(SyntaxFactory.ThrowStatement(SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.IdentifierName("ArgumentException")) + .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList( + [ + SyntaxFactory.Argument(SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal("The payload must be a parsed JSON element."))), + SyntaxFactory.Argument(EmissionSyntax.Invocation( + SyntaxFactory.IdentifierName("nameof"), + SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")))), + ]))))))); + statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, SyntaxFactory.IdentifierName("_marker"), diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs index c7d4ee4ba..2063775fc 100644 --- a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs +++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs @@ -12,6 +12,9 @@ internal sealed class GenerationWriter(IFileSystem fileSystem, IProjectFormatter private const string ManifestRelativePath = ".generated-manifest.json"; private const string MarkerRelativePath = ".generation-incomplete"; private static readonly StringComparer OwnedPathComparer = StringComparer.OrdinalIgnoreCase; + + /// Admitted family folders are single plain segments that never shadow a statically owned directory. + private static readonly string[] StaticallyOwnedFolders = ["Models", "Internal", "Pagination", "Properties"]; private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); private readonly IProjectFormatter _formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); @@ -47,9 +50,6 @@ public async Task WriteAsync(GenerationWriteRequest request, Cancel return Compare(before, after, request.Verify); } - /// Admitted family folders are single plain segments that never shadow a statically owned directory. - private static readonly string[] StaticallyOwnedFolders = ["Models", "Internal", "Properties"]; - private static HashSet ValidateFamilyFolders(IReadOnlyList familyFolders) { // Owned paths compare case-insensitively (Windows/macOS filesystems), so the From 4a174394dca6a2d49f994b1bdcdb92e91ac22767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 15:25:47 +0300 Subject: [PATCH 46/47] docs: anchor the second-review queue with triggers in the operational state --- docs/ROADMAP.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 02b3f0e12..b84fa60c0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -50,9 +50,15 @@ showcase. The PR #26 external review ran through adversarial verification (36 fi 30 confirmed / 4 plausible / 2 refuted), and the verified fix batch landed 2026-08-15 red-test-first — all ten merge blockers plus the small confirmed fold-ins, with Q91 sealed (research log Session 23, doc 16: the caller-owned HttpClient constructor stays public -behind a fail-closed anonymous-mode guard) and 904 tests green. Every surviving -non-blocker lives in issues #27–#30 and the #24 hygiene comment. Merge is awaiting the -maintainer's word; further breadth batches follow merge. +behind a fail-closed anonymous-mode guard). A second full-diff review (15 verified +findings) closed the same day: six runtime/emitter fixes landed on the branch (timeouts +route onto the transport spine, conflicting BaseAddress refuses, handle guards match the +route guards, carrier payload guards, count-guard identity, the Pagination shadow wall) +for 932 green tests, and every surviving finding lives milestone-anchored with an explicit +trigger — #31 (M2, DI lifetime shape, decision-first), #32 (M3, EscapeDataString TFM +policy), #33 (M3, carrier hand-construction semantics, rides #23), #34 (M2, contract-test +consolidation), plus the #27 and #23 enrichments. Merge is awaiting the maintainer's word; +further breadth batches follow merge. ## Milestones From f2a409f7dd8da9d0c6ed4e0ef649faf4eb2068ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C4=B0rgin?= Date: Sat, 15 Aug 2026 16:27:27 +0300 Subject: [PATCH 47/47] =?UTF-8?q?docs(agents):=20hand=20off=20the=20M3=20p?= =?UTF-8?q?ickup=20=E2=80=94=20plan-first=20opening,=20triggered=20queue,?= =?UTF-8?q?=20perf=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/ROADMAP.md | 4 +- .../handover-prompts/HANDOFF-2026-08-14-3.md | 166 ------------------ .../handover-prompts/HANDOFF-2026-08-15.md | 129 ++++++++++++++ 3 files changed, 132 insertions(+), 167 deletions(-) delete mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md create mode 100644 docs/agents/handover-prompts/HANDOFF-2026-08-15.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b84fa60c0..da500213b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -89,7 +89,9 @@ is revisited at each milestone boundary. durable-stream design does not carry over and is re-derived here. Demo: watching a real session's event stream. The net472 `ServicePointManager` item lands here. The union single-pass deserialization and streaming adapter-boundary redesign (#23) land - on the M3 runway, gated on the performance baselines (#18). + on the M3 runway, gated on the performance baselines (#18), together with the + second-review perf mechanisms and #29; #32 (EscapeDataString TFM policy) rides the + net472 cluster and #33 (carrier hand-construction semantics) rides #23. 4. **M4 — Launcher.** `OpenCodeServer.StartAsync` with three-OS acceptance (ADR-0001) over `opencode2 serve`; demo: the SDK starts the server itself and calls health. The net472 stdout/tree-kill items land here. (`serve --stdio`'s stdin leash and the diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md b/docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md deleted file mode 100644 index bdb06ae0f..000000000 --- a/docs/agents/handover-prompts/HANDOFF-2026-08-14-3.md +++ /dev/null @@ -1,166 +0,0 @@ -# Session Handoff — M2 close-out: Q91 research, verified-finding fix batch, merge, M3 pickup - -Date: 2026-08-14 - -Consume this document against live Git; delete it when M3 opens with its own plan. It -supersedes `HANDOFF-2026-08-14-2.md` (deleted with this one's creation): that handoff's -alignment batch (Q83–Q85) landed, the follow-on construction/options/DI reshape (Q90) -landed, and its external review cycle ran through adversarial verification. What remains -is the verified-finding fix batch — gated on one open decision — then merge, then M3. - -## Onboarding Reading Order - -1. `AGENTS.md`, then every ADR (0001–0009); 0003/0004/0007/0008/0009 are load-bearing - for this work. -2. Research log Sessions 19–22 (Q76–Q90) — the M2 seals, the alignment batch, the Q90 - construction/options/DI reshape, and the upstream re-research (dual-channel location, - optional auth, discovery file). -3. `docs/engineering/coding-style.md` (§4 layout) and `docs/engineering/testing-style.md`. -4. `docs/ROADMAP.md` — status, milestone anchors, open questions (Q91 listed there). -5. Review artifacts (transient, gitignored — read before the fix batch): - `.scratchpad/ultra-review-m2/REPORT.md` (finding ids 1–15), `REPORT-EXTRAS.md` - (ids 16–36), `verify/RESULTS.json` (all 36 verdicts with evidence and - `fix_assessment` — the machine input for the batch), `verify/VERIFICATION-REPORT.md` - (human synthesis). Durable extracts already live in issues #27–#30 and the #24 - comment; if scratchpad is gone, this handoff's summaries plus those issues suffice. - -## Git, Workspace, And PR State - -| Item | State | -|---|---| -| Branch | `feature/m2-first-breadth-batch` @ `5daeffb`+ (pushed); PR **#26** open against `master` | -| Tree | clean; all six gates green; 830 tests | -| Review | 36 findings adversarially verified: 30 CONFIRMED / 4 PLAUSIBLE / 2 REFUTED; **10 merge blockers** (ids 1–7, 9, 12, 14) | -| Issues | #27 (M5 naming walls), #28 (M2 curation mutual-exclusion — `needs-triage`, decision first), #29 (M3 perf, rides #23), #30 (M2 PrintMembers cursor); #24 carries seven new hygiene items as a comment | -| Open decision | **Q91 — BYO HttpClient surface** (work item A below); blocker #1's fix shape depends on it | - -## Work Queue (in this order) - -### A. Q91 — BYO-HttpClient surface: industry research, then seal (FIRST) - -Blocker #1 (default `Authorization` on an injected client leaks into requests the docs -call anonymous) exposed the cost side of the public `(HttpClient, options)` constructor. -**Deliberately not sealed** — research first. - -- **Research (fresh evidence, not recollection):** how Azure.Core/System.ClientModel, - OpenAI, AWS, Stripe.net, Octokit, and the typed-client ecosystem treat caller-supplied - transport: public BYO constructor or not; documented default-header semantics (whose - headers win); how no-DI consumers get proxy/TLS/custom handlers; whether any SDK - restricts transport injection to its DI companion package. -- **Recorded leaning (maintainer + session, 2026-08-14): internalize.** Make the - `(HttpClient, options)` constructor `internal` + `InternalsVisibleTo("OpenCode.Sdk.Extensions")` - (Tests/Performance IVTs already exist), leaving one public constructor; the Extensions - `IHttpClientBuilder` becomes the sanctioned transport-composition seam; future no-DI - needs (proxy et al.) are met additively via concrete options knobs — never a raw - handler pass-through. -- **Acceptance bar if BYO stays public:** a first-class but pragmatic — not - overengineered — resolution of the default-header ambiguity: at minimum a - construction-time fail-closed guard (`Password == null` while the injected client's - `DefaultRequestHeaders` carry `Authorization` → refuse) plus docs stating whose - headers win. The proxy/TLS advantage of BYO is real; do not discard it silently. -- Batch the research table + recommendation to the maintainer as plain text; **seal - before touching #1**. - -### B. Verified-blocker fix batch — red-test-first, commit per green increment - -Authoritative fix guidance: `verify/RESULTS.json` `fix_assessment` per id. Summaries -(durable, scratchpad-independent): - -- **#1** (Pipeline, sev 5) — shape decided by Q91; guard + doc alignment at minimum. -- **#2** (SchemaPlanBinder + ModelEmitter, sev 4) — the big one: plan gains a wire-null - bit (schema-admitted null vs optional-absence nullability), emitter gains an AOT-safe - JSON-null rejection for optional non-collection properties whose schema does not admit - null; rejection must be `JsonException` so adapters map it to - `OpenCodeTransportException`. Design before code. -- **#3** (EnvelopeDtoEmitter/EnvelopeEmitter, sev 4) — cursor-list `data` rejects null - elements on both the DTO deserialization path and the public initializer. -- **#4** (ModelEmitter, sev 4) — strict string-enum converter - (`allowIntegerValues: false`, AOT-safe, shared); regenerate the six enums. -- **#5** (SchemaNodeComparer, sev 3) — `Format` joins alias deep-identity; format-drift - refusal test (Q81 contract). -- **#6** (OperationPlanBinder, sev 4) — model-name collisions with - `ReservedSpineTypeNames` become explicit diagnostics (`UnionWith` hides them today). -- **#7** (SpecBinder, sev 3) — envelope-DTO names collision-checked against model names - before the registry `Distinct` collapses identities. -- **#9** (SpecBinder, sev 3) — property-override schema keys canonicalize through the - alias map before `TypePlanBinder`; duplicate logical keys refuse. -- **#12** (SchemaNameResolver + OperationPlanBinder, sev 3) — selected operations' - component-referenced bodies get `RequestTypeName` through a selected-ops ownership map - (pending operations must not rename shared components); `BindRequestBody` asserts the - resolved name. **Fold #8:** an explicit body+query refusal in the wire-shape check, - independent of name collision, with a component-body regression (Q83's wall must not - depend on naming luck). -- **#14** (UnionEmitter, sev 3) — concrete carrier converters get `HandleNull`; - **fold #13:** validate `UnionPlan.FixedMarker` (presence, kind, exact value) in both - carrier and base converters; keep unknown-inner-marker tolerance. - -Non-blocker fold-ins (fix in the same batch — small, all CONFIRMED): - -- **#15** owned-transport leak when the Pipeline ctor throws after `CreateOwnedHttpClient()`. -- **#20** family-folder shadow check becomes case-insensitive (`models` vs `Models/`). -- **#21** `QueryStringBuilder` escapes parameter names, not just values. -- **#23** `Username` XML doc tone: the pinned server layer hardcodes `opencode`; - `--username`/`OPENCODE_SERVER_USERNAME` is upstream direction (docs + desktop sidecar), - not pinned-server behavior. -- **#25** ROADMAP still promises the deleted env fallback (the #20-decision sentence in - the status paragraph) — align with Q90. -- **#30** `UserAgentPolicy.Resolve()` result caches statically (transient DI clients - re-reflect today). - -**Do not fix** (REFUTED): #11 (opaque-ID pass-through is the recorded convention), -#24 (premature — shape not yet admitted). **Not in this batch** (already issued): -#27, #28, #29, #30-issue, #24-comment items. - -Mechanics: regen ritual after emitter/binder changes; every fix opens red; the PublicApi -baseline diff is maintainer review surface; perf-relevant changes carry numbers. - -### C. Finalize and merge - -- PR #26 body gains one fix-batch section (keep existing content); test count updates. - `Closes #19/#21/#22` stays. -- Offer the maintainer a second, fix-diff-scoped review pass (refresh the range in - `.scratchpad/ultra-review-m2/ultra-review.md` if accepted) — their call, don't assume. -- ROADMAP status refresh rides the final batch commit. Merge on the maintainer's word; - the branch retires. - -### D. M3 pickup - -- **Write the short (1–2 page) M3 plan first**; align with the maintainer before any - execution. Planning opens with the **location + merged-Request input design session** - (sealed: research log Session 22 Q88; census and mechanisms in research doc 15 - §5a/§6 — 61 deepObject `location` ops, all 15 body+query mixes are location-caused, - `session.list` is the flat exception; server precedence query > header). -- Scope pointers: SSE engine (`v2.event.subscribe`; `v2.session.log` with - `after`/`follow`), the paginator over the `ListRequest` seam, #23 + #29 perf - (benchmark-gated), the net472 `ServicePointManager` item, spec-pin refresh sanctioned - at the boundary (the submodule pointer moves in lockstep — `spec/SNAPSHOT.md` - procedure). - -## Session Mechanics The Docs Don't Carry - -- Dev loop: all six gates before every commit, chained under `set -o pipefail` with an - explicit ALL-GREEN marker; commit per green increment; push on the working branch is - authorized; red-test-first is demanded. -- **Decisions to the maintainer go as plain text in chat — never AskUserQuestion - dialogs.** Batch them with a recommendation; engage their notes in prose before - touching code. -- Regen ritual: edit emitters → `dotnet run --file tools/opencode-tool.cs -- generate` - → review the committed-output diff → snapshots received→verified after review → - `generate --verify` byte-identical before commit. -- Multi-TFM traps are paid for — see the agent memory; new addition: Sonar's - test-project probe misses TUnit-only projects (S1075 fired on - `OpenCode.Sdk.Extensions.Tests`; arbitrate via csproj `NoWarn` + one-line - `slopwatch-ignore: SW005` comment). -- Scratchpad is transient: any fact the future needs must land in issues, docs, or this - handoff's successor before the session ends. - -## Stop Conditions - -The prior list carries (a binder needing raw OpenAPI access, name-branching emitters, -structural unions, ownership ambiguity, TFM breakage, partial packs; a body+query -operation admitted without the sealed merged-Request design; Extensions dependencies -outside research doc 06's policy; any M3 stream design bypassing plan-first), plus: - -- Executing blocker #1's fix before Q91 is sealed. -- Adding any curation surface without a maintainer seal (#28 waits for its decision). -- "Fixing" the REFUTED findings #11 or #24. diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-15.md b/docs/agents/handover-prompts/HANDOFF-2026-08-15.md new file mode 100644 index 000000000..fc72a7cd8 --- /dev/null +++ b/docs/agents/handover-prompts/HANDOFF-2026-08-15.md @@ -0,0 +1,129 @@ +# Session Handoff — M3 pickup: plan-first opening after the M2 first-breadth merge + +Date: 2026-08-15 + +Consume this document against live Git; delete it when the M3 plan is written and agreed. +It supersedes `HANDOFF-2026-08-14-3.md` (deleted with this one's creation): that handoff's +whole queue completed — Q91 sealed, the ten verified merge blockers plus fold-ins fixed +red-test-first, a second full-diff review ran and closed the same day (six more fixes on +the branch, everything else issued with triggers), and PR #26 merged. What remains is the +small #34 test-consolidation PR and the M3 plan. + +## What the closing session decided (beyond the docs) + +- **Q91 sealed** (research log Session 23, doc 16): the caller-owned `(HttpClient, + options)` constructor stays public; anonymous mode refuses a default `Authorization` + fail-closed at construction and per send; with a password set the SDK's per-request + header wins and the precedence is documented. +- **Second review (15 verified findings)**: six landed on the branch — timeouts wrap into + `OpenCodeTransportException` (caller-token-untouched cancellation only), conflicting + injected `BaseAddress` refuses loudly, handle construction applies the shared + route-value guard (`EmissionSyntax.RouteValueGuard`), carrier constructors refuse + unparsed payloads, the count guard carries the `request` parameter identity, and + `Pagination` joined the writer's shadow wall. Every surviving finding lives + milestone-anchored **with an explicit Trigger section** in #31–#34 and the #27/#23 + enrichment comments — that trigger convention is now the standing shape for + review-queue issues. +- **Comment hygiene is enforced**: comments in code artifacts state the status quo only — + no doc/ADR citations, no decision dates/attribution, no milestone scoping, no future + promises, no review-finding ids (repo-wide sweep, commit `7ac5bf3`). Member ordering is + a repo rule: access modifier first, then usage order. +- **MockSeam stays** as-is for now; the instructive mock guard has no ecosystem precedent + (gRPC/Google throw messageless `NotImplementedException`; Azure/OpenAI ship nothing) + and is a deliberate defensive refinement. A rename (`MockingConstructorGuard`-style) + remains an open low-stakes option; note it at M6 if the maintainer wants it. + +## Git, Workspace, And Issue State + +| Item | State | +|---|---| +| Branch | `master` after the PR #26 merge; `feature/m2-first-breadth-batch` retired | +| Tests | 932 across all legs; all six gates green at every commit | +| Issues (all trigger-annotated) | **#31** M2, DI lifetime shape — decision-first; **#32** M3, EscapeDataString TFM policy; **#33** M3, carrier hand-construction semantics (rides #23); **#34** M2, contract-test consolidation (ready-for-agent); **#27** +5 wall gaps (R2-8..12; R2-8 likely fires next batch); **#23** + the cursor-list re-copy mechanism; #28/#29/#30/#24 unchanged | +| CI | Three-OS matrix healthy again (an Actions spending-limit outage on 2026-08-15 was billing, not code) | + +## Onboarding Reading Order + +1. `AGENTS.md`, then every ADR (0001–0009). +2. Research log Sessions 19–23 (Q76–Q91) — the M2 seals, the Q90 reshape, the upstream + re-research, and the Q91 BYO-transport seal (census in doc 16). +3. `docs/engineering/coding-style.md` and `testing-style.md`. +4. `docs/ROADMAP.md` — status, milestone anchors, open questions. +5. Research doc 15 §5a/§6 — the location census the M3 design session consumes. + +## Work Queue (in this order) + +### A. #34 — contract-test consolidation (small standalone PR) + +Ready-for-agent; land it before the next batch multiplies contract tests. One shared +scaffold home per testing-style §1, single `FixtureLoader`/`RecordingHttpHandler` pair +across test projects, wire bodies out of inline literals, plus the missing +object-envelope `{"data":null}` contract test. + +### B. M3 plan — write the short (1–2 page) plan first; align before any execution + +Planning opens with the **location + merged-Request input design session** (sealed: +Session 22 Q88). Census facts to design against (doc 15 §5a/§6): 61 deepObject +`location` ops; all 15 body+query mixes are location-caused; `session.list` is the flat +exception; server precedence query > header. The body+query wire-shape refusal landed in +the fix batch — admission waits on this design. + +M3 scope pointers beyond that session: + +- SSE engine (`v2.event.subscribe`; `v2.session.log` with `after`/`follow` — resume + guarantees still unestablished), stream endpoints hand-wired per ADR-0008. +- The paginator over the `ListRequest` seam and `ListCursor`. +- The net472 cluster: `ServicePointManager` hardening **and #32** (EscapeDataString TFM + policy — refuse/normalize/tolerate needs a seal). +- **The deliberate performance pass** (see below). +- #33's carrier hand-construction contract decides while #23 rewrites the converters. +- Spec-pin refresh is sanctioned at this boundary (submodule pointer moves in lockstep — + `spec/SNAPSHOT.md` procedure). + +Decision-first items to put before the maintainer early: #31 (DI lifetime shape — M2 +anchored, must seal before Extensions grows), #28 (curation mutual-exclusion), #32, #33. + +### C. Performance notes (maintainer-directed, 2026-08-15) + +- **SDK performance work lands at M3, deliberately, in one pass**: the three confirmed + hot-path mechanisms — UTF-16 body materialization + re-transcode, union converter + JsonDocument double-parse, cursor-list re-copy after converter validation — all + converge on #23's adapter-boundary redesign. Do them together under `MemoryDiagnoser`, + allocation-first, with before/after numbers in the PR (posture: benchmarks settle + claims, never argument). Doing them before the SSE engine reshapes buffering would be + double work. +- **Until then the discipline is baseline-keeping, not optimizing**: the perf project's + baselines (`GetMessageAsync` 56.2 μs; `ListMessagesAsync` 58.1 μs / 28.24 KB) must not + regress through breadth batches. +- **Tooling performance waits for M6** (`refresh-spec` + nightly canary; posture: speed + only, no allocation budget). Early trigger: the regen ritual noticeably slowing the + dev loop pulls #24's measured-first items (V31/V32) forward. + +## Session Mechanics The Docs Don't Carry + +- Dev loop: all six gates before every commit under `set -o pipefail` with an explicit + ALL-GREEN marker; red-test-first; commit per green increment; push on the working + branch authorized inside an agreed loop; decisions to the maintainer as plain text with + options and a recommendation — never dialog widgets; engage their notes in prose + before touching code. +- Regen ritual: emitters → `dotnet run --file tools/opencode-tool.cs -- generate` → + review the committed diff → snapshots received→verified after review → + `generate --verify` before commit. +- Review-queue issues carry a **Trigger** section and a milestone in the title; full + finding text goes into the issue body so nothing lives only in transient scratchpads. +- Multi-TFM traps are recorded in agent memory; Sonar's test-project probe misses + TUnit-only projects (arbitrate via csproj `NoWarn` + one-line slopwatch-ignore). +- The maintainer reads answers, not tool traffic; batch decisions, lead with the + recommendation. + +## Stop Conditions + +The prior list carries (a binder needing raw OpenAPI access, name-branching emitters, +structural unions, ownership ambiguity, TFM breakage, partial packs; any body+query +operation admitted without the sealed merged-Request design; Extensions dependencies +outside research doc 06's policy; any M3 stream design bypassing plan-first), plus: + +- Changing the Extensions registration shape before #31's decision seals. +- Any curation surface without a maintainer seal (#28 still waits). +- Optimizing SDK hot paths outside the #23-anchored M3 pass, or claiming performance + without benchmark numbers.