diff --git a/Directory.Build.props b/Directory.Build.props
index fb2f25fd..621f8169 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -105,6 +105,10 @@
+
+ true
+
+
all
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 00000000..e28ddd6f
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6df319f6..fc4a1e41 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,6 +3,7 @@
+
@@ -17,6 +18,7 @@
+
@@ -27,6 +29,7 @@
+
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 929e8ece..9f5b0403 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,22 +1,18 @@
# Roadmap
-Date: 2026-08-11
+Date: 2026-08-12
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/`.
## Status
-Structural skeleton, three-OS CI, the pinned spec snapshot, and the Slice 0 tooling skeleton
-are in place. The generator-complexity checkpoint (research doc 13; research log session 15)
-redirected the build-out: the design specs under `superpowers/` are vision/reference
-documents (the sealed surface is the ADRs plus `AGENTS.md`), the slice map is retired in
-favor of the milestone list below, and ingestion policy moved from exhaustive DOM
-surveillance to semantic-risk fail-closed validation. Slice 1 closed lean on
-`feature/slice-01-ingestion-specir`: `ISpecIngestion` projects the complete pin (both
-surfaces, landmark smoke green, deterministic) behind the Microsoft.OpenApi boundary
-guards; `generate` remains a fail-loud stub until M1's compiler arc. Issue #2 closes with
-that branch's PR.
+Structural skeleton, three-OS CI, the pinned spec snapshot, Slice 0 tooling, and complete-pin
+SpecIR ingestion are in place. M1's compiler arc selects the two walking-skeleton operations,
+binds their reachable closure, emits committed Roslyn-built models plus source-generated JSON
+metadata, and owns deterministic `generate` / `generate --verify` writes through a guarded
+manifest. The partial-operation marker blocks every package while breadth remains incomplete.
+The next M1 arc is the minimal transport and callable client with typed errors and `NoThrow`.
## Milestones
diff --git a/docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md b/docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md
deleted file mode 100644
index b8f4ac8f..00000000
--- a/docs/agents/handover-prompts/HANDOFF-2026-08-11-2.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# Handover — M1 walking skeleton (post course-correction)
-
-Date: 2026-08-11
-
-Priming prompt for the M1 session. Consume against live git; delete when the M1 plan is
-written and approved (or fold anything still unconsumed into that plan). Transient and
-agent-facing.
-
-## How we got here — read this before anything else
-
-The project ran a heavy research/design runway first (research log sessions 1–14): three
-design specs, ADRs 0001–0009, and a 12-slice implementation map, all sealed through
-repeated grill sessions. Slice 0 (tooling skeleton) landed fine. **Slice 1 (ingestion)
-then produced 8.6K lines with zero SDK output** — a semantic projection buried under
-DOM-surveillance machinery (reflection member whitelists over every reader type, planned
-document-wide hashes, DOM inventories, SpecIR snapshots) plus a 1.5K-line test DSL, all
-before any generator, model, or client existed.
-
-A mid-execution **complexity checkpoint** (research doc 13) ran two probes that decided
-everything after it: (1) the production projection **absorbs the complete pin** — 188
-operations, 472 named schemas, all landmarks, deterministically — so the semantic core is
-right; (2) raw **Kiota loses this dialect's wire data** (drops `type` markers, loses whole
-union payloads, silences unknown tags, no typed error bodies) — so the own-generator
-decision is right. The failure was therefore not architecture but process, and the
-maintainer sealed a course correction (research log **session 15**, Q63–Q65):
-
-- **Cause 1:** fail-closed maximalism was transplanted from the analyzer wall (cheap
- there) onto the reader DOM, where a whitelist's cost scales with the *library's*
- surface, not with product risk.
-- **Cause 2:** the slice map was a layer cake in vertical clothing — first callable
- client at slice 5, first real request at slice 7; feedback arrived after maximal
- investment.
-- **Cause 3:** paper grill sessions ratchet monotonically (every risk gains a mechanism;
- nothing answers "what does this cost in code?"), and assurance intensity was never
- scaled by blast radius.
-
-The Slice 1 branch was then **closed lean in the same session**: surveillance machinery
-deleted (−851/+131), semantic-loss checks folded into projectors, `ISpecIngestion`
-orchestration + `$ref`-sibling raw scan + dangling-ref sweep added, tripwire/boundary/
-landmark tests written, docs regime changed. PR #14 (squash) closed issue #2.
-
-## What is true now (the standing rules)
-
-- **Sealed surface = ADRs + `AGENTS.md` only.** The specs under `docs/superpowers/` are
- vision/reference (bannered): direction and rationale, not law. Contradicting one is a
- level-0 note; the deviation protocol's level 2 covers canonical docs only.
-- **Red lines (maintainer):** the analyzer wall + TWAE stays exactly as is; the
- Testably/`IFileSystem` seam with the TestableIO enforcement analyzer stays;
- `Microsoft.OpenApi` (pinned 3.9.0) stays the reader.
-- **Drift radar:** git diffs of committed generated output + the test suites are primary;
- targeted validators exist only at known lossy seams (unrecognized raw keywords,
- `prefixItems` tripwire, `$ref` siblings, dangling refs, boundary guards, landmark
- smoke). No DOM inventories, no SpecIR snapshots, no speculative hashes.
-- **Consumer-pull:** every SpecIR fact, mechanism, option, and test names a consumer or a
- concrete failure it prevents. A fact needed by a later feature is added *in* that
- feature.
-- **Blast-radius assurance** (`AGENTS.md`): shipped SDK runtime highest; committed
- generated output next; repo tooling internals lightest.
-- **Delivery model:** `docs/ROADMAP.md` carries six milestones (M1 walking skeleton → M6
- operational closure). Each milestone gets a **1–2 page** plan written when it starts.
- The slice map and per-slice issue chain are retired; issues #3–#12 need re-triage
- against the milestone model (maintainer's call — do not bulk-close on your own).
-- Test authorship: builders/scenarios exist under `tests/OpenCode.Sdk.Tools.Tests/Support/`
- — use them, **grow them only when a test pulls a new verb**. Named scenario classes only
- on the promotion rule. No count assertions against the pin.
-
-## Live state
-
-| Item | Value |
-|---|---|
-| master | PR #14 squash-merged (lean Slice 1); issue #2 closed |
-| Ingestion | `ISpecIngestion` → `SpecDocument` under `tools/OpenCode.Sdk.Tools/Generator/Ingestion/`; absorbs the full pin (188 ops = 61 modern/127 legacy, 1,501 graph nodes, landmark smoke green, repeat-deterministic) |
-| `generate` | still a fail-loud stub — becomes real in M1 arc A |
-| Hosting | full ToolApp DI composition live (MEL Spectre/file providers, global `--log-level`/`--log-file`, interceptor, `IFileSystem`/`IAnsiConsole` seams) |
-| Tests | 168 green (Tools.Tests 164 + Sdk.Tests smoke ×4 TFM legs) |
-| Verify.TUnit | pinned + referenced but **currently unused** — its first consumer is arc A's emitter micro-snapshots (the other sanctioned use: the public API surface lock) |
-| Evidence worktree | `C:\Users\deniz\AppData\Local\Temp\opencode\opencode-sdk-dotnet-slice-01-parser-specir` (branch `feature/slice-01-parser-specir`, retired parser) — READ-ONLY, disposition is the maintainer's call |
-| SpecDocument gaps (deliberate) | no content hashes (return with the fingerprint feature in M5), no version string (the 3.1 gate guarantees it) |
-
-## The task: M1 — walking skeleton
-
-**Sealed deliverable:** `v2.health.get` + `v2.session.message` end to end — SpecDocument →
-Binder → EmitPlan → Roslyn emitters → committed source under `src/OpenCode.Sdk` → minimal
-transport core → callable client — **demonstrated once by hand against a real
-`opencode serve`**, output pasted into the PR. Deliberately process-free: no launcher, no
-test harness, no CI integration legs, no coverage gate. Two independently mergeable arcs:
-
-- **Arc A — compiler + committed models:** two-operation selection profile (a checked-in
- text file under `tools/`), sparse curation v0 with selection-scoped coverage checks,
- Binder → EmitPlan, model/union/registry emitters for the selected reachable closure,
- Writer (output manifest, stale cleanup, `dotnet format` post-step), real `generate` +
- `--verify`; the committed models compile on all five TFMs.
-- **Arc B — callable client:** exception/response/request-option spines,
- `Pipeline.ExecuteAsync` behavior core (endpoint + BYO HttpClient construction, basic
- auth chain, directory header, User-Agent, cancellation — **no** retry/telemetry/hooks/
- DI-extensions yet), envelope/routes/client emitters, the two generated methods, level-2
- contract tests for both operations and their declared errors, the manual demo.
-
-**First step of the session: write the M1 plan (1–2 pages), get the maintainer's OK, then
-execute.** The walking-skeleton spec
-(`docs/superpowers/specs/2026-08-11-production-walking-skeleton-design.md`) is the design
-reference — take its decisions (operation selection semantics, semantic-risk policy, the
-`SessionMessage` converter contract, error mapping table, stop conditions), not its
-ceremony; its verification matrices are a menu, not a checklist. Keep the partial-breadth
-guard to the cheapest mechanical form that actually blocks `dotnet pack`.
-
-`v2.session.message` is chosen because its closure exercises the hard path: bound session
-handle, two path parameters, `{data}` envelope, an 8-variant marked union whose assistant
-branch nests a 3-variant content union and a 4-state tool-state union, declared
-400/401/404 errors (404 dedups to two not-found variants). Its unions are all *marked* —
-if the closure unexpectedly pulls in a structural union, that is the deferred emission
-decision surfacing early: stop and take it to the maintainer as an API review.
-
-## Onboarding order
-
-1. `AGENTS.md` (note the vision/reference sentence and the blast-radius testing sentence)
- + `docs/agents/deviation-protocol.md` (narrowed level 2)
-2. `docs/ROADMAP.md` — status + the six milestones
-3. Research log **session 15** (Q63–Q65) + research doc
- `13-generator-complexity-checkpoint.md` — the why behind everything above
-4. The walking-skeleton spec (vision status — design reference for M1)
-5. The ingestion seam it builds on: `Generator/Ingestion/` source +
- `SpecIngestionTests`/`PinnedSpecSmokeTests` (what `SpecDocument` carries; the
- handoff-to-Binder facts: `SpecEnvelopeShape`, `ErrorStyle`, `LiteralMarker`s,
- `UnionClassification`, `IsSse`/`EffectStreamJson`, `IsWebSocket`,
- `SpecMediaType.Stripped`, `Description`/`Format`, `IsDeprecated`)
-6. `docs/engineering/coding-style.md` + `testing-style.md` — binding on every task
-7. Public API + generator specs — consult per topic while planning (envelope §5, naming
- §8, curation §5, emitters §6, file mechanics §7, Writer §8); do not re-read whole
-
-## Warnings
-
-- **Do not resurrect the deleted machinery** — reflection member whitelists, DOM
- member/default inventories, SpecIR-of-the-pin snapshots, document-wide raw hashes,
- fatal unknown `x-*`, per-keyword refusal tests. Session 15 records why each died. A new
- assurance mechanism needs a consumer or a demonstrated failure, in writing.
-- Typed annotation/validation members (`title`, `default`, `readOnly`, bounds…) are
- **silently ignored** by design; unrecognized *raw* keywords still refuse (nothing
- downstream can ever see them). Don't "fix" either direction.
-- The landmark smoke is the refresh gate: a red landmark is evidence about the wire —
- stop and classify, never patch the assertion.
-- Plans stay 1–2 pages; per-task commits on the arc branch are the agreed loop; master
- merges via PR with maintainer approval; squash-merge is the house style since PR #14.
-- Never modify the evidence worktree or its branch.
-- The maintainer works in Turkish conversation, English artifacts; align on structure
- before writing documents; canonical-doc edits need explicit per-edit approval.
diff --git a/docs/superpowers/plans/2026-08-11-m1-walking-skeleton.md b/docs/superpowers/plans/2026-08-11-m1-walking-skeleton.md
new file mode 100644
index 00000000..5245c6ce
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-11-m1-walking-skeleton.md
@@ -0,0 +1,188 @@
+# M1 Walking Skeleton Implementation Plan
+
+Date: 2026-08-11
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
+> `superpowers:subagent-driven-development` (recommended) or
+> `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox
+> (`- [ ]`) syntax for tracking.
+
+**Goal:** Land a callable, pre-release SDK surface for `v2.health.get` and
+`v2.session.message`, demonstrated once against a real `opencode serve`.
+
+**Architecture:** `ISpecIngestion` remains the only OpenAPI boundary. A selection-scoped
+Binder turns `SpecDocument` plus sparse curation into role-specific EmitPlan records; dumb
+Roslyn emitters produce committed source, and generated methods delegate once into a
+hand-written HTTP behavior core. Arc A lands the compiler/models; Arc B lands the client.
+
+**Tech Stack:** .NET SDK 10.0.302, C# 14, Microsoft.OpenApi 3.9.0,
+Microsoft.CodeAnalysis.CSharp 5.6.0, System.Text.Json 10.0.11, CliWrap 3.10.4, TUnit,
+Verify.TUnit, Testably.Abstractions, and PublicApiGenerator 11.5.4.
+
+## Global Constraints
+
+- Binding decisions are `AGENTS.md` plus ADRs 0001-0009; design specs are reference only.
+- Emit exactly the two selected modern operations. Pending operations are reported, not
+ fingerprinted, and keep `dotnet pack` blocked by an owned `.generation-incomplete` marker.
+- Clients, options, responses, routes, and exceptions use `OpenCode.Sdk`; generated domain and
+ error models use `OpenCode.Sdk.Models`; serializer infrastructure is internal.
+- The first payload members are `SessionMessageResponse.Message` and
+ `HealthResponse.Healthy`; health's one-value boolean enum emits as `bool`.
+- Every marked union has a position-independent converter and explicit `Unknown*` carrier with
+ its tag and a cloned raw `JsonElement`; serialization uses only generated metadata.
+- Generated output is deterministic, LF-only, manifest-owned, analyzer-visible plain `.cs`.
+- Product code targets `netstandard2.0;net472;net8.0;net9.0;net10.0` and uses
+ `ConfigureAwait(false)`. Tests obey `docs/engineering/testing-style.md`.
+- Exclude retry, telemetry, hooks, DI Extensions, launcher, SSE, process harness, coverage/CI
+ legs, hashes, fingerprints, full curation, and unselected operation support.
+
+## File Map
+
+- Inputs: `tools/generation-profile.txt`, `tools/curation.json`.
+- Compiler: `tools/OpenCode.Sdk.Tools/Generator/{Binding,Emission,Output}/` and
+ `Generator/GenerationCoordinator.cs`.
+- Product: generated `src/OpenCode.Sdk/{Models,Serialization,Internal}/` plus root clients and
+ responses; hand-written root spines plus `Internal/Pipeline.cs`.
+- Tests: matching `Generator/` areas in Tools.Tests and
+ `{Serialization,Contract,Support,Fixtures}/` in Sdk.Tests.
+
+---
+
+## Arc A - Selected Compiler And Committed Models
+
+Use `feature/m1-compiler-models`; each task is one reviewable commit. Merge Arc A before Arc B.
+
+### Task 1: Selection, Curation, And Binder
+
+**Files:** Create the two input files; `Binding/Abstractions/ISpecBinder.cs`;
+`Binding/{SpecBinder,OperationSelectionLoader,CurationLoader,ReachableSchemaCollector,
+CSharpNamePolicy}.cs`; role records under `Binding/Models/`; matching loader/Binder tests.
+
+**Interface:** `ISpecBinder.Bind(SpecDocument, OperationSelection, GenerationCuration)` returns
+`EmitPlan` containing only model, marked-union, registry, and pending-operation plans.
+
+- [ ] Red-test missing/duplicate IDs, unknown config fields, orphan/pending curation rows,
+ selected coverage gaps, batched errors, closure, duplicate-ref collapse, and determinism.
+- [ ] Check in the exact two-operation profile and sparse curation: root `health`; `Sessions` +
+ `SessionClient`; payload `Message`; reasoned `Uri` overrides for
+ `PromptFileAttachment.uri` and `ToolFileContent.uri`.
+- [ ] Bind only the selected closure, require every reached union to be marked, apply FDG names
+ (`Llm*`), and derive pending modern/legacy sets without full-pin count assertions.
+- [ ] Run `dotnet test tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj
+ --configuration Release`; commit `feat(generator): bind the M1 operation selection`.
+
+### Task 2: Model, Union, And Registry Emitters
+
+**Files:** Run `dotnet add tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj package
+Microsoft.CodeAnalysis.CSharp --version 5.6.0`; create
+`Emission/{GeneratedSource,SourceEmitter,ModelEmitter,UnionEmitter,RegistryEmitter}.cs` and
+matching tests with one Verify micro-snapshot per emitter family.
+
+**Interface:** `SourceEmitter.Emit(EmitPlan)` returns an ordinal-sorted
+`IReadOnlyList`; each item carries a manifest-relative path and UTF-8 source.
+Emitters consume no SpecIR, curation, or filesystem.
+
+- [ ] Red-test immutable records, `required`, empty optional collections, wire names, XML docs,
+ error inheritance, marked dispatch, unknown round-trip, and registry membership.
+- [ ] Emit Roslyn trees for models, `OpenCodeError`, concrete errors, union bases/variants,
+ `Unknown*` carriers, converters, and internal `OpenCodeJsonContext`.
+- [ ] Compile emitted snippets, approve only the three micro-snapshots, run
+ `dotnet test tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj --configuration
+ Release`, and commit `feat(generator): emit selected models and unions`.
+
+### Task 3: Writer, Generate Command, And Arc A Gate
+
+**Files:** Run `dotnet add src/OpenCode.Sdk/OpenCode.Sdk.csproj package System.Text.Json
+--version 10.0.11` and `dotnet add tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj package
+CliWrap --version 3.10.4`; create `Generator/{GenerationCoordinator,GenerationRequest,
+GenerationReport}.cs`,
+`Output/Abstractions/{IGenerationWriter,IProjectFormatter}.cs`, and
+`Output/{GenerationWriter,CliWrapProjectFormatter,GenerationManifest,WriteResult}.cs`; modify
+`GenerateCommand.cs`, `ToolApp.cs`, and `OpenCode.Sdk.csproj`; add product serialization tests
+and embedded fixtures; remove `SmokeTests.cs` after real tests cover each test TFM.
+
+- [ ] Unit-test manifest path safety, stale cleanup, unrelated-file preservation, repeat output,
+ clean/drift `--verify`, report/marker behavior, and command exits using MockFileSystem and a
+ substituted formatter.
+- [ ] Implement write-then-format ownership. `--verify` snapshots old/new owned paths, writes,
+ formats the SDK project, byte-compares owned output, and reports changed/created/deleted paths
+ without Git.
+- [ ] Generate and commit `Models/`, internal converters/context, `.generated-manifest.json`,
+ and `.generation-incomplete`; product-test known/unknown outer, assistant-content, and
+ tool-state variants, arbitrary marker order, semantic round-trip, and context-only metadata.
+- [ ] Run `dotnet run --file tools/opencode-tool.cs -- generate`, repeat with `--verify`, then
+ run Release build/tests and format. Run
+ `dotnet pack src/OpenCode.Sdk/OpenCode.Sdk.csproj --configuration Release` separately and
+ require failure solely from the partial marker.
+- [ ] Commit `feat(generator): commit the M1 model closure`; open and merge the Arc A PR.
+
+---
+
+## Arc B - Callable Client
+
+Use `feature/m1-callable-client` from merged Arc A.
+
+### Task 4: Hand-Written Behavior Core
+
+**Files:** Create root `{OpenCodeClientOptions,OpenCodeRequestOptions,ErrorBehavior,
+OpenCodeResponse,OpenCodeException,OpenCodeApiException,OpenCodeTransportException}.cs`;
+`Internal/Abstractions/IEnvironmentProvider.cs`;
+`Internal/{SystemEnvironmentProvider,Pipeline,ResponseAdapter}.cs`; SDK friend assembly metadata;
+recording-handler/environment test support and focused runtime tests.
+
+**Interface:** `Pipeline.ExecuteAsync(HttpMethod, string,
+ResponseAdapter, OpenCodeRequestOptions?, CancellationToken)` returns
+`Task` where `TResponse : OpenCodeResponse`. Generated adapters own typed success/error
+deserialization and envelope construction; Pipeline owns transport and throw/`NoThrow` behavior.
+
+- [ ] Red-test endpoint joining; explicit password then one-time
+ `OPENCODE_SERVER_PASSWORD` fallback with Basic user `opencode`; directory precedence;
+ `x-opencode-directory`; `OpenCode.Sdk/` User-Agent; cancellation;
+ transport/protocol errors; response/HttpClient disposal.
+- [ ] Implement only those behaviors. Preserve `OperationCanceledException`; expose status,
+ typed `OpenCodeError`, and raw body on `OpenCodeApiException`; never dispose BYO HttpClient.
+ Options expose `Endpoint`, `Password`, and client `Directory`; request options expose
+ `ErrorBehavior` (`Default`/`NoThrow`), per-call `Directory`, and static `NoThrow`;
+ `OpenCodeResponse` exposes `Status`, `IsError`, and `Error`.
+- [ ] Run `dotnet test tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj --configuration
+ Release`; commit
+ `feat(sdk): add the minimal HTTP behavior core`.
+
+### Task 5: Client Emission, Contracts, And M1 Closure
+
+**Files:** Add consumed-only `{EnvelopePlan,ClientPlan,OperationPlan,ErrorMapPlan}` binding records;
+create `Emission/{EnvelopeEmitter,RoutesEmitter,ClientEmitter,OperationMethodEmitter,
+ResponseAdapterEmitter}.cs`; generate root clients/routes/responses and two internal adapters;
+run `dotnet add tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj package PublicApiGenerator
+--version 11.5.4`; add `Contract/{HealthContract,
+SessionMessageContract}Tests.cs`, API approval, recording support, and embedded payload fixtures.
+
+**Public surface:** `OpenCodeClient` implements `IDisposable`, has `(Uri)`,
+`(Uri, OpenCodeClientOptions)`, `(HttpClient, OpenCodeClientOptions)`, and protected mock
+constructors, readonly `Sessions`, and `GetHealthAsync(options, cancellationToken)`;
+`SessionsClient.GetSessionClient(sessionId)` returns `SessionClient`, whose
+`GetMessageAsync(messageId, options, cancellationToken)` returns `SessionMessageResponse`.
+
+- [ ] Emit final clients, guarded/escaped IDs and routes, guarded `Healthy`/`Message` getters and
+ `PrintMembers`, one-expression method delegation, and status maps: health 400/401; message
+ 400/401/404 with both deduplicated not-found variants and `UnknownOpenCodeError` fallback.
+- [ ] Contract-test every selected success/declared error through default and `NoThrow`; test
+ unknown retention, path escaping, auth/directory/User-Agent, cancellation, transport failure,
+ and client ownership through the real generated client.
+- [ ] Approve the first API baseline; run `generate --verify`, Release build/tests, format, and
+ Slopwatch. Keep the partial marker and pack refusal.
+- [ ] Start `OPENCODE_SERVER_PASSWORD=m1-demo opencode serve --port 4096` in a directory with an
+ existing message; obtain IDs from `GET /api/session?limit=1` and
+ `GET /api/session/{sessionID}/message?limit=1`; use a temporary `.scratchpad/` console to print
+ `Healthy`, concrete message type, and message ID; paste output into the PR.
+- [ ] Update `docs/ROADMAP.md`, commit `feat(sdk): generate the first callable client`, and open
+ the Arc B PR.
+
+## Final Gates And Stop Conditions
+
+Run `dotnet build --configuration Release`, `dotnet test --configuration Release --no-build`,
+`dotnet format --verify-no-changes --no-restore`, and `dotnet tool run slopwatch analyze
+--exclude ".scratchpad/**,external/**" --fail-on warning`. Stop for maintainer review if the
+closure reaches a structural union, Binder needs Microsoft.OpenApi/raw JSON, a temporary public
+or transport path would later be replaced, output cannot compile uniformly on all five TFMs,
+partial breadth can pack, or excluded M1 capabilities become necessary for either call.
diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json
new file mode 100644
index 00000000..185076f4
--- /dev/null
+++ b/src/OpenCode.Sdk/.generated-manifest.json
@@ -0,0 +1,61 @@
+{
+ "files": [
+ "Internal/Serialization/LlmToolContentJsonConverter.cs",
+ "Internal/Serialization/OpenCodeErrorJsonConverter.cs",
+ "Internal/Serialization/OpenCodeJsonContext.cs",
+ "Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs",
+ "Internal/Serialization/SessionMessageJsonConverter.cs",
+ "Internal/Serialization/SessionMessageToolStateJsonConverter.cs",
+ "Models/InvalidRequestError.cs",
+ "Models/LlmToolContent.cs",
+ "Models/MessageNotFoundError.cs",
+ "Models/ModelRef.cs",
+ "Models/OpenCodeError.cs",
+ "Models/PromptAgentAttachment.cs",
+ "Models/PromptFileAttachment.cs",
+ "Models/PromptSource.cs",
+ "Models/SessionErrorUnknown.cs",
+ "Models/SessionMessage.cs",
+ "Models/SessionMessageAgentSwitched.cs",
+ "Models/SessionMessageAgentSwitchedTime.cs",
+ "Models/SessionMessageAssistant.cs",
+ "Models/SessionMessageAssistantContent.cs",
+ "Models/SessionMessageAssistantReasoning.cs",
+ "Models/SessionMessageAssistantReasoningTime.cs",
+ "Models/SessionMessageAssistantSnapshot.cs",
+ "Models/SessionMessageAssistantText.cs",
+ "Models/SessionMessageAssistantTime.cs",
+ "Models/SessionMessageAssistantTokens.cs",
+ "Models/SessionMessageAssistantTokensCache.cs",
+ "Models/SessionMessageAssistantTool.cs",
+ "Models/SessionMessageAssistantToolProvider.cs",
+ "Models/SessionMessageAssistantToolTime.cs",
+ "Models/SessionMessageCompaction.cs",
+ "Models/SessionMessageCompactionReason.cs",
+ "Models/SessionMessageCompactionTime.cs",
+ "Models/SessionMessageModelSwitched.cs",
+ "Models/SessionMessageModelSwitchedTime.cs",
+ "Models/SessionMessageShell.cs",
+ "Models/SessionMessageShellTime.cs",
+ "Models/SessionMessageSynthetic.cs",
+ "Models/SessionMessageSyntheticTime.cs",
+ "Models/SessionMessageSystem.cs",
+ "Models/SessionMessageSystemTime.cs",
+ "Models/SessionMessageToolState.cs",
+ "Models/SessionMessageToolStateCompleted.cs",
+ "Models/SessionMessageToolStateError.cs",
+ "Models/SessionMessageToolStatePending.cs",
+ "Models/SessionMessageToolStateRunning.cs",
+ "Models/SessionMessageUser.cs",
+ "Models/SessionMessageUserTime.cs",
+ "Models/SessionNotFoundError.cs",
+ "Models/ToolFileContent.cs",
+ "Models/ToolTextContent.cs",
+ "Models/UnauthorizedError.cs",
+ "Models/UnknownLlmToolContent.cs",
+ "Models/UnknownOpenCodeError.cs",
+ "Models/UnknownSessionMessage.cs",
+ "Models/UnknownSessionMessageAssistantContent.cs",
+ "Models/UnknownSessionMessageToolState.cs"
+ ]
+}
diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete
new file mode 100644
index 00000000..c1434933
--- /dev/null
+++ b/src/OpenCode.Sdk/.generation-incomplete
@@ -0,0 +1,195 @@
+Generation is incomplete; packages must not be published.
+Selected operations: 2
+Pending modern operations: 59
+Pending legacy operations: 127
+Selected:
+- v2.health.get
+- v2.session.message
+Pending modern:
+- v2.agent.list
+- v2.command.list
+- v2.credential.remove
+- v2.credential.update
+- v2.event.subscribe
+- v2.fs.find
+- v2.fs.list
+- v2.fs.read
+- v2.integration.attempt.cancel
+- v2.integration.attempt.complete
+- v2.integration.attempt.status
+- v2.integration.connect.key
+- v2.integration.connect.oauth
+- v2.integration.get
+- v2.integration.list
+- v2.location.get
+- v2.model.list
+- v2.permission.request.list
+- v2.permission.saved.list
+- v2.permission.saved.remove
+- v2.projectCopy.create
+- v2.projectCopy.refresh
+- v2.projectCopy.remove
+- v2.provider.get
+- v2.provider.list
+- v2.pty.connect
+- v2.pty.connectToken
+- v2.pty.create
+- v2.pty.get
+- v2.pty.list
+- v2.pty.remove
+- v2.pty.update
+- v2.question.request.list
+- v2.reference.list
+- v2.session.active
+- v2.session.compact
+- v2.session.context
+- v2.session.create
+- v2.session.events
+- v2.session.get
+- v2.session.history
+- v2.session.interrupt
+- v2.session.list
+- v2.session.messages
+- v2.session.permission.create
+- v2.session.permission.get
+- v2.session.permission.list
+- v2.session.permission.reply
+- v2.session.prompt
+- v2.session.question.list
+- v2.session.question.reject
+- v2.session.question.reply
+- v2.session.revert.clear
+- v2.session.revert.commit
+- v2.session.revert.stage
+- v2.session.switchAgent
+- v2.session.switchModel
+- v2.session.wait
+- v2.skill.list
+Pending legacy:
+- app.agents
+- app.log
+- app.skills
+- auth.remove
+- auth.set
+- command.list
+- config.get
+- config.providers
+- config.update
+- event.subscribe
+- experimental.capabilities.get
+- experimental.console.get
+- experimental.console.listOrgs
+- experimental.console.switchOrg
+- experimental.controlPlane.moveSession
+- experimental.projectCopy.generateName
+- experimental.resource.list
+- experimental.session.background
+- experimental.session.list
+- experimental.workspace.adapter.list
+- experimental.workspace.create
+- experimental.workspace.list
+- experimental.workspace.remove
+- experimental.workspace.status
+- experimental.workspace.syncList
+- experimental.workspace.warp
+- file.list
+- file.read
+- file.status
+- find.files
+- find.symbols
+- find.text
+- formatter.status
+- global.config.get
+- global.config.update
+- global.dispose
+- global.event
+- global.health
+- global.upgrade
+- instance.dispose
+- lsp.status
+- mcp.add
+- mcp.auth.authenticate
+- mcp.auth.callback
+- mcp.auth.remove
+- mcp.auth.start
+- mcp.connect
+- mcp.disconnect
+- mcp.status
+- part.delete
+- part.update
+- path.get
+- permission.list
+- permission.reply
+- permission.respond
+- project.current
+- project.directories
+- project.initGit
+- project.list
+- project.update
+- provider.auth
+- provider.list
+- provider.oauth.authorize
+- provider.oauth.callback
+- pty.connect
+- pty.connectToken
+- pty.create
+- pty.get
+- pty.list
+- pty.remove
+- pty.shells
+- pty.update
+- question.list
+- question.reject
+- question.reply
+- session.abort
+- session.children
+- session.command
+- session.create
+- session.delete
+- session.deleteMessage
+- session.diff
+- session.fork
+- session.get
+- session.init
+- session.list
+- session.message
+- session.messages
+- session.prompt
+- session.prompt_async
+- session.revert
+- session.share
+- session.shell
+- session.status
+- session.summarize
+- session.todo
+- session.unrevert
+- session.unshare
+- session.update
+- sync.history.list
+- sync.replay
+- sync.start
+- sync.steal
+- tool.ids
+- tool.list
+- tui.appendPrompt
+- tui.clearPrompt
+- tui.control.next
+- tui.control.response
+- tui.executeCommand
+- tui.openHelp
+- tui.openModels
+- tui.openSessions
+- tui.openThemes
+- tui.publish
+- tui.selectSession
+- tui.showToast
+- tui.submitPrompt
+- vcs.apply
+- vcs.diff
+- vcs.diff.raw
+- vcs.get
+- vcs.status
+- worktree.create
+- worktree.list
+- worktree.remove
+- worktree.reset
diff --git a/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs
new file mode 100644
index 00000000..0927249d
--- /dev/null
+++ b/src/OpenCode.Sdk/Internal/Serialization/LlmToolContentJsonConverter.cs
@@ -0,0 +1,68 @@
+// 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 LlmToolContentJsonConverter : JsonConverter
+{
+ private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal)
+ {
+ ["file"] = typeof(ToolFileContent),
+ ["text"] = typeof(ToolTextContent)
+ };
+ public override bool HandleNull => true;
+
+ public override LlmToolContent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(typeToConvert);
+ ArgumentNullException.ThrowIfNull(options);
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ throw new JsonException("The LlmToolContent payload cannot be null.");
+ }
+
+ using var document = JsonDocument.ParseValue(ref reader);
+ var payload = document.RootElement;
+ if (payload.ValueKind != JsonValueKind.Object)
+ {
+ throw new JsonException("The LlmToolContent payload must be a JSON object.");
+ }
+
+ if (!payload.TryGetProperty("type", out var markerElement))
+ {
+ throw new JsonException("The LlmToolContent payload must contain 'type'.");
+ }
+
+ if (markerElement.ValueKind != JsonValueKind.String)
+ {
+ throw new JsonException("The 'type' marker must be a string.");
+ }
+
+ var marker = markerElement.GetString() ?? throw new JsonException("The 'type' marker cannot be null.");
+ if (TypesByTag.TryGetValue(marker, out var targetType))
+ {
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for LlmToolContent.");
+ return JsonSerializer.Deserialize(payload, typeInfo) as LlmToolContent ?? throw new JsonException("The LlmToolContent payload deserialized to null.");
+ }
+
+ return new UnknownLlmToolContent(marker, payload);
+ }
+
+ public override void Write(Utf8JsonWriter writer, LlmToolContent value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ if (value is UnknownLlmToolContent unknown)
+ {
+ unknown.Payload.WriteTo(writer);
+ return;
+ }
+
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for LlmToolContent.");
+ JsonSerializer.Serialize(writer, value, typeInfo);
+ }
+}
diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs
new file mode 100644
index 00000000..c90778bc
--- /dev/null
+++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeErrorJsonConverter.cs
@@ -0,0 +1,70 @@
+// 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 OpenCodeErrorJsonConverter : JsonConverter
+{
+ private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal)
+ {
+ ["InvalidRequestError"] = typeof(InvalidRequestError),
+ ["MessageNotFoundError"] = typeof(MessageNotFoundError),
+ ["SessionNotFoundError"] = typeof(SessionNotFoundError),
+ ["UnauthorizedError"] = typeof(UnauthorizedError)
+ };
+ public override bool HandleNull => true;
+
+ public override OpenCodeError 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)
+ {
+ 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() ?? throw new JsonException("The '_tag' marker cannot be null.");
+ if (TypesByTag.TryGetValue(marker, out var targetType))
+ {
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for OpenCodeError.");
+ return JsonSerializer.Deserialize(payload, typeInfo) as OpenCodeError ?? throw new JsonException("The OpenCodeError payload deserialized to null.");
+ }
+
+ return new UnknownOpenCodeError(marker, payload);
+ }
+
+ public override void Write(Utf8JsonWriter writer, OpenCodeError value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ if (value is UnknownOpenCodeError unknown)
+ {
+ unknown.Payload.WriteTo(writer);
+ return;
+ }
+
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for OpenCodeError.");
+ JsonSerializer.Serialize(writer, value, typeInfo);
+ }
+}
diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs
new file mode 100644
index 00000000..2d9e8576
--- /dev/null
+++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs
@@ -0,0 +1,62 @@
+// 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;
+
+[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata, RespectNullableAnnotations = true)]
+[JsonSerializable(typeof(InvalidRequestError))]
+[JsonSerializable(typeof(LlmToolContent))]
+[JsonSerializable(typeof(MessageNotFoundError))]
+[JsonSerializable(typeof(ModelRef))]
+[JsonSerializable(typeof(OpenCodeError))]
+[JsonSerializable(typeof(PromptAgentAttachment))]
+[JsonSerializable(typeof(PromptFileAttachment))]
+[JsonSerializable(typeof(PromptSource))]
+[JsonSerializable(typeof(SessionErrorUnknown))]
+[JsonSerializable(typeof(SessionMessage))]
+[JsonSerializable(typeof(SessionMessageAgentSwitched))]
+[JsonSerializable(typeof(SessionMessageAgentSwitchedTime))]
+[JsonSerializable(typeof(SessionMessageAssistant))]
+[JsonSerializable(typeof(SessionMessageAssistantContent))]
+[JsonSerializable(typeof(SessionMessageAssistantReasoning))]
+[JsonSerializable(typeof(SessionMessageAssistantReasoningTime))]
+[JsonSerializable(typeof(SessionMessageAssistantSnapshot))]
+[JsonSerializable(typeof(SessionMessageAssistantText))]
+[JsonSerializable(typeof(SessionMessageAssistantTime))]
+[JsonSerializable(typeof(SessionMessageAssistantTokens))]
+[JsonSerializable(typeof(SessionMessageAssistantTokensCache))]
+[JsonSerializable(typeof(SessionMessageAssistantTool))]
+[JsonSerializable(typeof(SessionMessageAssistantToolProvider))]
+[JsonSerializable(typeof(SessionMessageAssistantToolTime))]
+[JsonSerializable(typeof(SessionMessageCompaction))]
+[JsonSerializable(typeof(SessionMessageCompactionReason))]
+[JsonSerializable(typeof(SessionMessageCompactionTime))]
+[JsonSerializable(typeof(SessionMessageModelSwitched))]
+[JsonSerializable(typeof(SessionMessageModelSwitchedTime))]
+[JsonSerializable(typeof(SessionMessageShell))]
+[JsonSerializable(typeof(SessionMessageShellTime))]
+[JsonSerializable(typeof(SessionMessageSynthetic))]
+[JsonSerializable(typeof(SessionMessageSyntheticTime))]
+[JsonSerializable(typeof(SessionMessageSystem))]
+[JsonSerializable(typeof(SessionMessageSystemTime))]
+[JsonSerializable(typeof(SessionMessageToolState))]
+[JsonSerializable(typeof(SessionMessageToolStateCompleted))]
+[JsonSerializable(typeof(SessionMessageToolStateError))]
+[JsonSerializable(typeof(SessionMessageToolStatePending))]
+[JsonSerializable(typeof(SessionMessageToolStateRunning))]
+[JsonSerializable(typeof(SessionMessageUser))]
+[JsonSerializable(typeof(SessionMessageUserTime))]
+[JsonSerializable(typeof(SessionNotFoundError))]
+[JsonSerializable(typeof(ToolFileContent))]
+[JsonSerializable(typeof(ToolTextContent))]
+[JsonSerializable(typeof(UnauthorizedError))]
+[JsonSerializable(typeof(UnknownLlmToolContent))]
+[JsonSerializable(typeof(UnknownOpenCodeError))]
+[JsonSerializable(typeof(UnknownSessionMessage))]
+[JsonSerializable(typeof(UnknownSessionMessageAssistantContent))]
+[JsonSerializable(typeof(UnknownSessionMessageToolState))]
+internal sealed partial class OpenCodeJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs
new file mode 100644
index 00000000..aca524b0
--- /dev/null
+++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs
@@ -0,0 +1,69 @@
+// 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 SessionMessageAssistantContentJsonConverter : JsonConverter
+{
+ private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal)
+ {
+ ["reasoning"] = typeof(SessionMessageAssistantReasoning),
+ ["text"] = typeof(SessionMessageAssistantText),
+ ["tool"] = typeof(SessionMessageAssistantTool)
+ };
+ public override bool HandleNull => true;
+
+ public override SessionMessageAssistantContent 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)
+ {
+ 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() ?? throw new JsonException("The 'type' marker cannot be null.");
+ if (TypesByTag.TryGetValue(marker, out var targetType))
+ {
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionMessageAssistantContent.");
+ return JsonSerializer.Deserialize(payload, typeInfo) as SessionMessageAssistantContent ?? throw new JsonException("The SessionMessageAssistantContent payload deserialized to null.");
+ }
+
+ return new UnknownSessionMessageAssistantContent(marker, payload);
+ }
+
+ public override void Write(Utf8JsonWriter writer, SessionMessageAssistantContent value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ if (value is UnknownSessionMessageAssistantContent unknown)
+ {
+ unknown.Payload.WriteTo(writer);
+ return;
+ }
+
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionMessageAssistantContent.");
+ JsonSerializer.Serialize(writer, value, typeInfo);
+ }
+}
diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs
new file mode 100644
index 00000000..68c5f841
--- /dev/null
+++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageJsonConverter.cs
@@ -0,0 +1,74 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenCode.Sdk.Models;
+
+namespace OpenCode.Sdk.Internal.Serialization;
+
+internal sealed class SessionMessageJsonConverter : JsonConverter
+{
+ private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal)
+ {
+ ["agent-switched"] = typeof(SessionMessageAgentSwitched),
+ ["assistant"] = typeof(SessionMessageAssistant),
+ ["compaction"] = typeof(SessionMessageCompaction),
+ ["model-switched"] = typeof(SessionMessageModelSwitched),
+ ["shell"] = typeof(SessionMessageShell),
+ ["synthetic"] = typeof(SessionMessageSynthetic),
+ ["system"] = typeof(SessionMessageSystem),
+ ["user"] = typeof(SessionMessageUser)
+ };
+ public override bool HandleNull => true;
+
+ public override SessionMessage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(typeToConvert);
+ ArgumentNullException.ThrowIfNull(options);
+ if (reader.TokenType == JsonTokenType.Null)
+ {
+ throw new JsonException("The SessionMessage payload cannot be null.");
+ }
+
+ using var document = JsonDocument.ParseValue(ref reader);
+ var payload = document.RootElement;
+ if (payload.ValueKind != JsonValueKind.Object)
+ {
+ throw new JsonException("The SessionMessage payload must be a JSON object.");
+ }
+
+ if (!payload.TryGetProperty("type", out var markerElement))
+ {
+ throw new JsonException("The SessionMessage payload must contain 'type'.");
+ }
+
+ if (markerElement.ValueKind != JsonValueKind.String)
+ {
+ throw new JsonException("The 'type' marker must be a string.");
+ }
+
+ var marker = markerElement.GetString() ?? throw new JsonException("The 'type' marker cannot be null.");
+ if (TypesByTag.TryGetValue(marker, out var targetType))
+ {
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionMessage.");
+ return JsonSerializer.Deserialize(payload, typeInfo) as SessionMessage ?? throw new JsonException("The SessionMessage payload deserialized to null.");
+ }
+
+ return new UnknownSessionMessage(marker, payload);
+ }
+
+ public override void Write(Utf8JsonWriter writer, SessionMessage value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ if (value is UnknownSessionMessage unknown)
+ {
+ unknown.Payload.WriteTo(writer);
+ return;
+ }
+
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionMessage.");
+ JsonSerializer.Serialize(writer, value, typeInfo);
+ }
+}
diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageToolStateJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageToolStateJsonConverter.cs
new file mode 100644
index 00000000..a7c3431c
--- /dev/null
+++ b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageToolStateJsonConverter.cs
@@ -0,0 +1,70 @@
+// 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 SessionMessageToolStateJsonConverter : JsonConverter
+{
+ private static readonly Dictionary TypesByTag = new(StringComparer.Ordinal)
+ {
+ ["completed"] = typeof(SessionMessageToolStateCompleted),
+ ["error"] = typeof(SessionMessageToolStateError),
+ ["pending"] = typeof(SessionMessageToolStatePending),
+ ["running"] = typeof(SessionMessageToolStateRunning)
+ };
+ public override bool HandleNull => true;
+
+ public override SessionMessageToolState 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)
+ {
+ 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() ?? throw new JsonException("The 'status' marker cannot be null.");
+ if (TypesByTag.TryGetValue(marker, out var targetType))
+ {
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionMessageToolState.");
+ return JsonSerializer.Deserialize(payload, typeInfo) as SessionMessageToolState ?? throw new JsonException("The SessionMessageToolState payload deserialized to null.");
+ }
+
+ return new UnknownSessionMessageToolState(marker, payload);
+ }
+
+ public override void Write(Utf8JsonWriter writer, SessionMessageToolState value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ if (value is UnknownSessionMessageToolState unknown)
+ {
+ unknown.Payload.WriteTo(writer);
+ return;
+ }
+
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionMessageToolState.");
+ JsonSerializer.Serialize(writer, value, typeInfo);
+ }
+}
diff --git a/src/OpenCode.Sdk/Models/InvalidRequestError.cs b/src/OpenCode.Sdk/Models/InvalidRequestError.cs
new file mode 100644
index 00000000..e41cac39
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/InvalidRequestError.cs
@@ -0,0 +1,36 @@
+// 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 request error value.
+///
+public sealed record InvalidRequestError : OpenCodeError
+{
+ ///
+ /// Gets the tag value.
+ ///
+ [JsonPropertyName("_tag")]
+ public override string Tag => "InvalidRequestError";
+
+ ///
+ /// Gets the message value.
+ ///
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+
+ ///
+ /// Gets the kind value.
+ ///
+ [JsonPropertyName("kind")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Kind { get; init; }
+
+ ///
+ /// Gets the field value.
+ ///
+ [JsonPropertyName("field")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Field { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/LlmToolContent.cs b/src/OpenCode.Sdk/Models/LlmToolContent.cs
new file mode 100644
index 00000000..7be23e7b
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/LlmToolContent.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 llm tool content union.
+///
+[JsonConverter(typeof(LlmToolContentJsonConverter))]
+public abstract record LlmToolContent
+{
+ ///
+ /// Gets the 'type' union marker.
+ ///
+ [JsonPropertyName("type")]
+ public abstract string Type { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/MessageNotFoundError.cs b/src/OpenCode.Sdk/Models/MessageNotFoundError.cs
new file mode 100644
index 00000000..4ee91c6b
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/MessageNotFoundError.cs
@@ -0,0 +1,34 @@
+// 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 message not found error value.
+///
+public sealed record MessageNotFoundError : OpenCodeError
+{
+ ///
+ /// Gets the tag value.
+ ///
+ [JsonPropertyName("_tag")]
+ public override string Tag => "MessageNotFoundError";
+
+ ///
+ /// Gets the session id value.
+ ///
+ [JsonPropertyName("sessionID")]
+ public required string SessionID { get; init; }
+
+ ///
+ /// Gets the message id value.
+ ///
+ [JsonPropertyName("messageID")]
+ public required string MessageID { get; init; }
+
+ ///
+ /// Gets the message value.
+ ///
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/ModelRef.cs b/src/OpenCode.Sdk/Models/ModelRef.cs
new file mode 100644
index 00000000..6b7bb617
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/ModelRef.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 model ref value.
+///
+public sealed record ModelRef
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the provider id value.
+ ///
+ [JsonPropertyName("providerID")]
+ public required string ProviderID { get; init; }
+
+ ///
+ /// Gets the variant value.
+ ///
+ [JsonPropertyName("variant")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Variant { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/OpenCodeError.cs b/src/OpenCode.Sdk/Models/OpenCodeError.cs
new file mode 100644
index 00000000..e0afed8e
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/OpenCodeError.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 typed error returned by the opencode API.
+///
+[JsonConverter(typeof(OpenCodeErrorJsonConverter))]
+public abstract record OpenCodeError
+{
+ ///
+ /// Gets the '_tag' union marker.
+ ///
+ [JsonPropertyName("_tag")]
+ public abstract string Tag { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs b/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs
new file mode 100644
index 00000000..f56e3923
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/PromptAgentAttachment.cs
@@ -0,0 +1,23 @@
+// 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 prompt agent attachment value.
+///
+public sealed record PromptAgentAttachment
+{
+ ///
+ /// Gets the name value.
+ ///
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the source value.
+ ///
+ [JsonPropertyName("source")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public PromptSource? Source { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/PromptFileAttachment.cs b/src/OpenCode.Sdk/Models/PromptFileAttachment.cs
new file mode 100644
index 00000000..f17bd341
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/PromptFileAttachment.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 System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a prompt file attachment value.
+///
+public sealed record PromptFileAttachment
+{
+ ///
+ /// Gets the uri value.
+ ///
+ [JsonPropertyName("uri")]
+ public required Uri Uri { get; init; }
+
+ ///
+ /// Gets the mime value.
+ ///
+ [JsonPropertyName("mime")]
+ public required string Mime { get; init; }
+
+ ///
+ /// Gets the name value.
+ ///
+ [JsonPropertyName("name")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Name { get; init; }
+
+ ///
+ /// Gets the description value.
+ ///
+ [JsonPropertyName("description")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Description { get; init; }
+
+ ///
+ /// Gets the source value.
+ ///
+ [JsonPropertyName("source")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public PromptSource? Source { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/PromptSource.cs b/src/OpenCode.Sdk/Models/PromptSource.cs
new file mode 100644
index 00000000..d1998bcc
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/PromptSource.cs
@@ -0,0 +1,28 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a prompt source value.
+///
+public sealed record PromptSource
+{
+ ///
+ /// Gets the start value.
+ ///
+ [JsonPropertyName("start")]
+ public required double Start { get; init; }
+
+ ///
+ /// Gets the end value.
+ ///
+ [JsonPropertyName("end")]
+ public required double End { get; init; }
+
+ ///
+ /// Gets the text value.
+ ///
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionErrorUnknown.cs b/src/OpenCode.Sdk/Models/SessionErrorUnknown.cs
new file mode 100644
index 00000000..ff316f61
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionErrorUnknown.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 error unknown value.
+///
+public sealed record SessionErrorUnknown
+{
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public required string Type { get; init; }
+
+ ///
+ /// Gets the message value.
+ ///
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessage.cs b/src/OpenCode.Sdk/Models/SessionMessage.cs
new file mode 100644
index 00000000..256ca83b
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessage.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 message union.
+///
+[JsonConverter(typeof(SessionMessageJsonConverter))]
+public abstract record SessionMessage
+{
+ ///
+ /// Gets the 'type' union marker.
+ ///
+ [JsonPropertyName("type")]
+ public abstract string Type { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAgentSwitched.cs b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitched.cs
new file mode 100644
index 00000000..092d3f0a
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitched.cs
@@ -0,0 +1,50 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message agent switched value.
+///
+public sealed record SessionMessageAgentSwitched : SessionMessage
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageAgentSwitchedTime Time { get; init; }
+
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "agent-switched";
+
+ ///
+ /// Gets the agent value.
+ ///
+ [JsonPropertyName("agent")]
+ public required string Agent { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAgentSwitchedTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitchedTime.cs
new file mode 100644
index 00000000..0d4f7dd1
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAgentSwitchedTime.cs
@@ -0,0 +1,16 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message agent switched time value.
+///
+public sealed record SessionMessageAgentSwitchedTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs
new file mode 100644
index 00000000..d4a1b338
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistant.cs
@@ -0,0 +1,105 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message assistant value.
+///
+public sealed record SessionMessageAssistant : SessionMessage
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageAssistantTime Time { get; init; }
+
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "assistant";
+
+ ///
+ /// Gets the agent value.
+ ///
+ [JsonPropertyName("agent")]
+ public required string Agent { get; init; }
+
+ ///
+ /// Gets the model value.
+ ///
+ [JsonPropertyName("model")]
+ public required ModelRef Model { get; init; }
+
+ ///
+ /// Gets the content value.
+ ///
+ [JsonPropertyName("content")]
+ public required IReadOnlyList Content
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new List(value).AsReadOnly();
+ }
+ } = [];
+
+ ///
+ /// Gets the snapshot value.
+ ///
+ [JsonPropertyName("snapshot")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public SessionMessageAssistantSnapshot? Snapshot { get; init; }
+
+ ///
+ /// Gets the finish value.
+ ///
+ [JsonPropertyName("finish")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Finish { get; init; }
+
+ ///
+ /// Gets the cost value.
+ ///
+ [JsonPropertyName("cost")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Cost { get; init; }
+
+ ///
+ /// Gets the tokens value.
+ ///
+ [JsonPropertyName("tokens")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public SessionMessageAssistantTokens? Tokens { get; init; }
+
+ ///
+ /// Gets the error value.
+ ///
+ [JsonPropertyName("error")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public SessionErrorUnknown? Error { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantContent.cs
new file mode 100644
index 00000000..4cef28bd
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantContent.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 message assistant content union.
+///
+[JsonConverter(typeof(SessionMessageAssistantContentJsonConverter))]
+public abstract record SessionMessageAssistantContent
+{
+ ///
+ /// Gets the 'type' union marker.
+ ///
+ [JsonPropertyName("type")]
+ public abstract string Type { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs
new file mode 100644
index 00000000..31ee4287
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs
@@ -0,0 +1,51 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message assistant reasoning value.
+///
+public sealed record SessionMessageAssistantReasoning : SessionMessageAssistantContent
+{
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "reasoning";
+
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the text value.
+ ///
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+
+ ///
+ /// Gets the provider metadata value.
+ ///
+ [JsonPropertyName("providerMetadata")]
+ public IReadOnlyDictionary> ProviderMetadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)) : new ReadOnlyDictionary>(value.ToDictionary(static pair => pair.Key, static pair => (IReadOnlyDictionary)new ReadOnlyDictionary(pair.Value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)), StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public SessionMessageAssistantReasoningTime? Time { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs
new file mode 100644
index 00000000..c03d7bbf
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoningTime.cs
@@ -0,0 +1,23 @@
+// 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 message assistant reasoning time value.
+///
+public sealed record SessionMessageAssistantReasoningTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+
+ ///
+ /// Gets the completed value.
+ ///
+ [JsonPropertyName("completed")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Completed { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs
new file mode 100644
index 00000000..13914338
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantSnapshot.cs
@@ -0,0 +1,38 @@
+// 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 message assistant snapshot value.
+///
+public sealed record SessionMessageAssistantSnapshot
+{
+ ///
+ /// Gets the start value.
+ ///
+ [JsonPropertyName("start")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Start { get; init; }
+
+ ///
+ /// Gets the end value.
+ ///
+ [JsonPropertyName("end")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? End { get; init; }
+
+ ///
+ /// Gets the files value.
+ ///
+ [JsonPropertyName("files")]
+ public IReadOnlyList Files
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? Array.Empty() : new List(value).AsReadOnly();
+ }
+ } = [];
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs
new file mode 100644
index 00000000..491ce784
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs
@@ -0,0 +1,28 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message assistant text value.
+///
+public sealed record SessionMessageAssistantText : SessionMessageAssistantContent
+{
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "text";
+
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the text value.
+ ///
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs
new file mode 100644
index 00000000..4d52d615
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTime.cs
@@ -0,0 +1,23 @@
+// 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 message assistant time value.
+///
+public sealed record SessionMessageAssistantTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+
+ ///
+ /// Gets the completed value.
+ ///
+ [JsonPropertyName("completed")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Completed { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTokens.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokens.cs
new file mode 100644
index 00000000..f324769e
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokens.cs
@@ -0,0 +1,34 @@
+// 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 message assistant tokens value.
+///
+public sealed record SessionMessageAssistantTokens
+{
+ ///
+ /// Gets the input value.
+ ///
+ [JsonPropertyName("input")]
+ public required double Input { get; init; }
+
+ ///
+ /// Gets the output value.
+ ///
+ [JsonPropertyName("output")]
+ public required double Output { get; init; }
+
+ ///
+ /// Gets the reasoning value.
+ ///
+ [JsonPropertyName("reasoning")]
+ public required double Reasoning { get; init; }
+
+ ///
+ /// Gets the cache value.
+ ///
+ [JsonPropertyName("cache")]
+ public required SessionMessageAssistantTokensCache Cache { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTokensCache.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokensCache.cs
new file mode 100644
index 00000000..078ed48a
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTokensCache.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 message assistant tokens cache value.
+///
+public sealed record SessionMessageAssistantTokensCache
+{
+ ///
+ /// Gets the read value.
+ ///
+ [JsonPropertyName("read")]
+ public required double Read { get; init; }
+
+ ///
+ /// Gets the write value.
+ ///
+ [JsonPropertyName("write")]
+ public required double Write { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs
new file mode 100644
index 00000000..7013ccb2
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs
@@ -0,0 +1,47 @@
+// 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 message assistant tool value.
+///
+public sealed record SessionMessageAssistantTool : SessionMessageAssistantContent
+{
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "tool";
+
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the name value.
+ ///
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the provider value.
+ ///
+ [JsonPropertyName("provider")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public SessionMessageAssistantToolProvider? Provider { get; init; }
+
+ ///
+ /// Gets the state value.
+ ///
+ [JsonPropertyName("state")]
+ public required SessionMessageToolState State { get; init; }
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageAssistantToolTime Time { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolProvider.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolProvider.cs
new file mode 100644
index 00000000..44f44229
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolProvider.cs
@@ -0,0 +1,46 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message assistant tool provider value.
+///
+public sealed record SessionMessageAssistantToolProvider
+{
+ ///
+ /// Gets the executed value.
+ ///
+ [JsonPropertyName("executed")]
+ public required bool Executed { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary> Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)) : new ReadOnlyDictionary>(value.ToDictionary(static pair => pair.Key, static pair => (IReadOnlyDictionary)new ReadOnlyDictionary(pair.Value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)), StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal));
+
+ ///
+ /// Gets the result metadata value.
+ ///
+ [JsonPropertyName("resultMetadata")]
+ public IReadOnlyDictionary> ResultMetadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal)) : new ReadOnlyDictionary>(value.ToDictionary(static pair => pair.Key, static pair => (IReadOnlyDictionary)new ReadOnlyDictionary(pair.Value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)), StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary>(new Dictionary>(StringComparer.Ordinal));
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.cs
new file mode 100644
index 00000000..12a3894e
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantToolTime.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;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message assistant tool time value.
+///
+public sealed record SessionMessageAssistantToolTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+
+ ///
+ /// Gets the ran value.
+ ///
+ [JsonPropertyName("ran")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Ran { get; init; }
+
+ ///
+ /// Gets the completed value.
+ ///
+ [JsonPropertyName("completed")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Completed { get; init; }
+
+ ///
+ /// Gets the pruned value.
+ ///
+ [JsonPropertyName("pruned")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Pruned { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompaction.cs b/src/OpenCode.Sdk/Models/SessionMessageCompaction.cs
new file mode 100644
index 00000000..de7ba3c0
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageCompaction.cs
@@ -0,0 +1,62 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message compaction value.
+///
+public sealed record SessionMessageCompaction : SessionMessage
+{
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "compaction";
+
+ ///
+ /// Gets the reason value.
+ ///
+ [JsonPropertyName("reason")]
+ public required SessionMessageCompactionReason Reason { get; init; }
+
+ ///
+ /// Gets the summary value.
+ ///
+ [JsonPropertyName("summary")]
+ public required string Summary { get; init; }
+
+ ///
+ /// Gets the recent value.
+ ///
+ [JsonPropertyName("recent")]
+ public required string Recent { get; init; }
+
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageCompactionTime Time { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionReason.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionReason.cs
new file mode 100644
index 00000000..e5b0238e
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionReason.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;
+///
+/// Defines the supported session message compaction reason values.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum SessionMessageCompactionReason
+{
+ ///
+ /// Represents the 'auto' wire value.
+ ///
+ [JsonStringEnumMemberName("auto")]
+ Auto,
+ ///
+ /// Represents the 'manual' wire value.
+ ///
+ [JsonStringEnumMemberName("manual")]
+ Manual
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionTime.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionTime.cs
new file mode 100644
index 00000000..e4ee8680
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionTime.cs
@@ -0,0 +1,16 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message compaction time value.
+///
+public sealed record SessionMessageCompactionTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageModelSwitched.cs b/src/OpenCode.Sdk/Models/SessionMessageModelSwitched.cs
new file mode 100644
index 00000000..b40383fd
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageModelSwitched.cs
@@ -0,0 +1,50 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message model switched value.
+///
+public sealed record SessionMessageModelSwitched : SessionMessage
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageModelSwitchedTime Time { get; init; }
+
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "model-switched";
+
+ ///
+ /// Gets the model value.
+ ///
+ [JsonPropertyName("model")]
+ public required ModelRef Model { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageModelSwitchedTime.cs b/src/OpenCode.Sdk/Models/SessionMessageModelSwitchedTime.cs
new file mode 100644
index 00000000..d01ce534
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageModelSwitchedTime.cs
@@ -0,0 +1,16 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message model switched time value.
+///
+public sealed record SessionMessageModelSwitchedTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageShell.cs b/src/OpenCode.Sdk/Models/SessionMessageShell.cs
new file mode 100644
index 00000000..d382d087
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageShell.cs
@@ -0,0 +1,62 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message shell value.
+///
+public sealed record SessionMessageShell : SessionMessage
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageShellTime Time { get; init; }
+
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "shell";
+
+ ///
+ /// Gets the call id value.
+ ///
+ [JsonPropertyName("callID")]
+ public required string CallID { get; init; }
+
+ ///
+ /// Gets the command value.
+ ///
+ [JsonPropertyName("command")]
+ public required string Command { get; init; }
+
+ ///
+ /// Gets the output value.
+ ///
+ [JsonPropertyName("output")]
+ public required string Output { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs b/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs
new file mode 100644
index 00000000..ffd023fd
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageShellTime.cs
@@ -0,0 +1,23 @@
+// 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 message shell time value.
+///
+public sealed record SessionMessageShellTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+
+ ///
+ /// Gets the completed value.
+ ///
+ [JsonPropertyName("completed")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Completed { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs
new file mode 100644
index 00000000..b617eb92
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageSynthetic.cs
@@ -0,0 +1,56 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message synthetic value.
+///
+public sealed record SessionMessageSynthetic : SessionMessage
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageSyntheticTime Time { get; init; }
+
+ ///
+ /// Gets the session id value.
+ ///
+ [JsonPropertyName("sessionID")]
+ public required string SessionID { get; init; }
+
+ ///
+ /// Gets the text value.
+ ///
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "synthetic";
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageSyntheticTime.cs b/src/OpenCode.Sdk/Models/SessionMessageSyntheticTime.cs
new file mode 100644
index 00000000..76740e03
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageSyntheticTime.cs
@@ -0,0 +1,16 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message synthetic time value.
+///
+public sealed record SessionMessageSyntheticTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageSystem.cs b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs
new file mode 100644
index 00000000..b15265d9
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageSystem.cs
@@ -0,0 +1,50 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message system value.
+///
+public sealed record SessionMessageSystem : SessionMessage
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageSystemTime Time { get; init; }
+
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "system";
+
+ ///
+ /// Gets the text value.
+ ///
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageSystemTime.cs b/src/OpenCode.Sdk/Models/SessionMessageSystemTime.cs
new file mode 100644
index 00000000..c74c5cf0
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageSystemTime.cs
@@ -0,0 +1,16 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message system time value.
+///
+public sealed record SessionMessageSystemTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolState.cs b/src/OpenCode.Sdk/Models/SessionMessageToolState.cs
new file mode 100644
index 00000000..3f69eed0
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageToolState.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 message tool state union.
+///
+[JsonConverter(typeof(SessionMessageToolStateJsonConverter))]
+public abstract record SessionMessageToolState
+{
+ ///
+ /// Gets the 'status' union marker.
+ ///
+ [JsonPropertyName("status")]
+ public abstract string Status { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.cs
new file mode 100644
index 00000000..c7fcfa05
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateCompleted.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.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message tool state completed value.
+///
+public sealed record SessionMessageToolStateCompleted : SessionMessageToolState
+{
+ ///
+ /// Gets the status value.
+ ///
+ [JsonPropertyName("status")]
+ public override string Status => "completed";
+
+ ///
+ /// Gets the input value.
+ ///
+ [JsonPropertyName("input")]
+ public required IReadOnlyDictionary Input
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the attachments value.
+ ///
+ [JsonPropertyName("attachments")]
+ public IReadOnlyList Attachments
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? Array.Empty() : new List(value).AsReadOnly();
+ }
+ } = [];
+
+ ///
+ /// Gets the content value.
+ ///
+ [JsonPropertyName("content")]
+ public required IReadOnlyList Content
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new List(value).AsReadOnly();
+ }
+ } = [];
+
+ ///
+ /// Gets the output paths value.
+ ///
+ [JsonPropertyName("outputPaths")]
+ public IReadOnlyList OutputPaths
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? Array.Empty() : new List(value).AsReadOnly();
+ }
+ } = [];
+
+ ///
+ /// Gets the structured value.
+ ///
+ [JsonPropertyName("structured")]
+ public required IReadOnlyDictionary Structured
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the result value.
+ ///
+ [JsonPropertyName("result")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public JsonElement? Result { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateError.cs
new file mode 100644
index 00000000..4500937a
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateError.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.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message tool state error value.
+///
+public sealed record SessionMessageToolStateError : SessionMessageToolState
+{
+ ///
+ /// Gets the status value.
+ ///
+ [JsonPropertyName("status")]
+ public override string Status => "error";
+
+ ///
+ /// Gets the input value.
+ ///
+ [JsonPropertyName("input")]
+ public required IReadOnlyDictionary Input
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the content value.
+ ///
+ [JsonPropertyName("content")]
+ public required IReadOnlyList Content
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new List(value).AsReadOnly();
+ }
+ } = [];
+
+ ///
+ /// Gets the structured value.
+ ///
+ [JsonPropertyName("structured")]
+ public required IReadOnlyDictionary Structured
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the error value.
+ ///
+ [JsonPropertyName("error")]
+ public required SessionErrorUnknown Error { get; init; }
+
+ ///
+ /// Gets the result value.
+ ///
+ [JsonPropertyName("result")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public JsonElement? Result { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStatePending.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStatePending.cs
new file mode 100644
index 00000000..6c89ec19
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageToolStatePending.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 message tool state pending value.
+///
+public sealed record SessionMessageToolStatePending : SessionMessageToolState
+{
+ ///
+ /// Gets the status value.
+ ///
+ [JsonPropertyName("status")]
+ public override string Status => "pending";
+
+ ///
+ /// Gets the input value.
+ ///
+ [JsonPropertyName("input")]
+ public required string Input { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs b/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs
new file mode 100644
index 00000000..983eb02f
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageToolStateRunning.cs
@@ -0,0 +1,60 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message tool state running value.
+///
+public sealed record SessionMessageToolStateRunning : SessionMessageToolState
+{
+ ///
+ /// Gets the status value.
+ ///
+ [JsonPropertyName("status")]
+ public override string Status => "running";
+
+ ///
+ /// Gets the input value.
+ ///
+ [JsonPropertyName("input")]
+ public required IReadOnlyDictionary Input
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the structured value.
+ ///
+ [JsonPropertyName("structured")]
+ public required IReadOnlyDictionary Structured
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the content value.
+ ///
+ [JsonPropertyName("content")]
+ public required IReadOnlyList Content
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new List(value).AsReadOnly();
+ }
+ } = [];
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageUser.cs b/src/OpenCode.Sdk/Models/SessionMessageUser.cs
new file mode 100644
index 00000000..4987e920
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageUser.cs
@@ -0,0 +1,78 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Collections.ObjectModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message user value.
+///
+public sealed record SessionMessageUser : SessionMessage
+{
+ ///
+ /// Gets the id value.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the metadata value.
+ ///
+ [JsonPropertyName("metadata")]
+ public IReadOnlyDictionary Metadata
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ ///
+ /// Gets the time value.
+ ///
+ [JsonPropertyName("time")]
+ public required SessionMessageUserTime Time { get; init; }
+
+ ///
+ /// Gets the text value.
+ ///
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+
+ ///
+ /// Gets the files value.
+ ///
+ [JsonPropertyName("files")]
+ public IReadOnlyList Files
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? Array.Empty() : new List(value).AsReadOnly();
+ }
+ } = [];
+
+ ///
+ /// Gets the agents value.
+ ///
+ [JsonPropertyName("agents")]
+ public IReadOnlyList Agents
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? Array.Empty() : new List(value).AsReadOnly();
+ }
+ } = [];
+
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "user";
+}
diff --git a/src/OpenCode.Sdk/Models/SessionMessageUserTime.cs b/src/OpenCode.Sdk/Models/SessionMessageUserTime.cs
new file mode 100644
index 00000000..8c84314e
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionMessageUserTime.cs
@@ -0,0 +1,16 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session message user time value.
+///
+public sealed record SessionMessageUserTime
+{
+ ///
+ /// Gets the created value.
+ ///
+ [JsonPropertyName("created")]
+ public required double Created { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/SessionNotFoundError.cs b/src/OpenCode.Sdk/Models/SessionNotFoundError.cs
new file mode 100644
index 00000000..092b396b
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/SessionNotFoundError.cs
@@ -0,0 +1,28 @@
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a session not found error value.
+///
+public sealed record SessionNotFoundError : OpenCodeError
+{
+ ///
+ /// Gets the tag value.
+ ///
+ [JsonPropertyName("_tag")]
+ public override string Tag => "SessionNotFoundError";
+
+ ///
+ /// Gets the session id value.
+ ///
+ [JsonPropertyName("sessionID")]
+ public required string SessionID { get; init; }
+
+ ///
+ /// Gets the message value.
+ ///
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/ToolFileContent.cs b/src/OpenCode.Sdk/Models/ToolFileContent.cs
new file mode 100644
index 00000000..698ab967
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/ToolFileContent.cs
@@ -0,0 +1,35 @@
+// 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 tool file content value.
+///
+public sealed record ToolFileContent : LlmToolContent
+{
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "file";
+
+ ///
+ /// Gets the uri value.
+ ///
+ [JsonPropertyName("uri")]
+ public required Uri Uri { get; init; }
+
+ ///
+ /// Gets the mime value.
+ ///
+ [JsonPropertyName("mime")]
+ public required string Mime { get; init; }
+
+ ///
+ /// Gets the name value.
+ ///
+ [JsonPropertyName("name")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Name { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/ToolTextContent.cs b/src/OpenCode.Sdk/Models/ToolTextContent.cs
new file mode 100644
index 00000000..0e285c2a
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/ToolTextContent.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 tool text content value.
+///
+public sealed record ToolTextContent : LlmToolContent
+{
+ ///
+ /// Gets the type value.
+ ///
+ [JsonPropertyName("type")]
+ public override string Type => "text";
+
+ ///
+ /// Gets the text value.
+ ///
+ [JsonPropertyName("text")]
+ public required string Text { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/UnauthorizedError.cs b/src/OpenCode.Sdk/Models/UnauthorizedError.cs
new file mode 100644
index 00000000..6cfc9b1a
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/UnauthorizedError.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 unauthorized error value.
+///
+public sealed record UnauthorizedError : OpenCodeError
+{
+ ///
+ /// Gets the tag value.
+ ///
+ [JsonPropertyName("_tag")]
+ public override string Tag => "UnauthorizedError";
+
+ ///
+ /// Gets the message value.
+ ///
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+}
diff --git a/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs b/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs
new file mode 100644
index 00000000..58b81984
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/UnknownLlmToolContent.cs
@@ -0,0 +1,32 @@
+// 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 llm tool content payload.
+///
+public sealed record UnknownLlmToolContent : LlmToolContent
+{
+ private readonly string _marker;
+ ///
+ /// Initializes an unknown union value from its marker and raw payload.
+ ///
+ [JsonConstructor]
+ public UnknownLlmToolContent(string type, JsonElement payload)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(type);
+ _marker = type;
+ Payload = payload.Clone();
+ }
+
+ ///
+ /// Gets the unrecognized 'type' marker.
+ ///
+ public override string Type => _marker;
+ ///
+ /// Gets the preserved raw JSON payload.
+ ///
+ public JsonElement Payload { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs
new file mode 100644
index 00000000..fa84ba41
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/UnknownOpenCodeError.cs
@@ -0,0 +1,32 @@
+// 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 open code error payload.
+///
+public sealed record UnknownOpenCodeError : OpenCodeError
+{
+ private readonly string _marker;
+ ///
+ /// Initializes an unknown union value from its marker and raw payload.
+ ///
+ [JsonConstructor]
+ public UnknownOpenCodeError(string tag, JsonElement payload)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(tag);
+ _marker = tag;
+ Payload = payload.Clone();
+ }
+
+ ///
+ /// Gets the unrecognized '_tag' marker.
+ ///
+ public override string Tag => _marker;
+ ///
+ /// Gets the preserved raw JSON payload.
+ ///
+ public JsonElement Payload { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs
new file mode 100644
index 00000000..464f80cb
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/UnknownSessionMessage.cs
@@ -0,0 +1,32 @@
+// 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 message payload.
+///
+public sealed record UnknownSessionMessage : SessionMessage
+{
+ private readonly string _marker;
+ ///
+ /// Initializes an unknown union value from its marker and raw payload.
+ ///
+ [JsonConstructor]
+ public UnknownSessionMessage(string type, JsonElement payload)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(type);
+ _marker = type;
+ Payload = payload.Clone();
+ }
+
+ ///
+ /// Gets the unrecognized 'type' marker.
+ ///
+ public override string Type => _marker;
+ ///
+ /// Gets the preserved raw JSON payload.
+ ///
+ public JsonElement Payload { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs
new file mode 100644
index 00000000..c903cb1a
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistantContent.cs
@@ -0,0 +1,32 @@
+// 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 message assistant content payload.
+///
+public sealed record UnknownSessionMessageAssistantContent : SessionMessageAssistantContent
+{
+ private readonly string _marker;
+ ///
+ /// Initializes an unknown union value from its marker and raw payload.
+ ///
+ [JsonConstructor]
+ public UnknownSessionMessageAssistantContent(string type, JsonElement payload)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(type);
+ _marker = type;
+ Payload = payload.Clone();
+ }
+
+ ///
+ /// Gets the unrecognized 'type' marker.
+ ///
+ public override string Type => _marker;
+ ///
+ /// Gets the preserved raw JSON payload.
+ ///
+ public JsonElement Payload { get; }
+}
diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs
new file mode 100644
index 00000000..547ff367
--- /dev/null
+++ b/src/OpenCode.Sdk/Models/UnknownSessionMessageToolState.cs
@@ -0,0 +1,32 @@
+// 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 message tool state payload.
+///
+public sealed record UnknownSessionMessageToolState : SessionMessageToolState
+{
+ private readonly string _marker;
+ ///
+ /// Initializes an unknown union value from its marker and raw payload.
+ ///
+ [JsonConstructor]
+ public UnknownSessionMessageToolState(string status, JsonElement payload)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(status);
+ _marker = status;
+ Payload = payload.Clone();
+ }
+
+ ///
+ /// Gets the unrecognized 'status' marker.
+ ///
+ public override string Status => _marker;
+ ///
+ /// Gets the preserved raw JSON payload.
+ ///
+ public JsonElement Payload { get; }
+}
diff --git a/src/OpenCode.Sdk/OpenCode.Sdk.csproj b/src/OpenCode.Sdk/OpenCode.Sdk.csproj
index 44d712b2..bfc3d1be 100644
--- a/src/OpenCode.Sdk/OpenCode.Sdk.csproj
+++ b/src/OpenCode.Sdk/OpenCode.Sdk.csproj
@@ -9,4 +9,8 @@
true
+
+
+
+
diff --git a/tests/OpenCode.Sdk.Tests/App.net472.config b/tests/OpenCode.Sdk.Tests/App.net472.config
new file mode 100644
index 00000000..1995750c
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/App.net472.config
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-assistant-content.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-assistant-content.json
new file mode 100644
index 00000000..b9f4ec4a
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-assistant-content.json
@@ -0,0 +1 @@
+{"id":"part-1","text":"answer","type":"text"}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session-message.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session-message.json
new file mode 100644
index 00000000..a9c5e639
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-session-message.json
@@ -0,0 +1 @@
+{"id":"message-1","time":{"created":1},"text":"hello","type":"user"}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-tool-state.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-tool-state.json
new file mode 100644
index 00000000..c3fd5c77
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/known-tool-state.json
@@ -0,0 +1 @@
+{"input":"queued input","status":"pending"}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/malformed-assistant-marker.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/malformed-assistant-marker.json
new file mode 100644
index 00000000..02e072c0
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/malformed-assistant-marker.json
@@ -0,0 +1 @@
+{"id":"part-4","text":"malformed marker","type":42}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/missing-assistant-marker.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/missing-assistant-marker.json
new file mode 100644
index 00000000..57d48e18
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/missing-assistant-marker.json
@@ -0,0 +1 @@
+{"id":"part-3","text":"missing marker"}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/non-object-assistant-payload.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/non-object-assistant-payload.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/non-object-assistant-payload.json
@@ -0,0 +1 @@
+[]
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-optional-collection.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-optional-collection.json
new file mode 100644
index 00000000..dcf0a8c9
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/null-optional-collection.json
@@ -0,0 +1 @@
+{"id":"message-3","metadata":null,"time":{"created":1},"text":"hello","type":"user"}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-assistant-content.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-assistant-content.json
new file mode 100644
index 00000000..16c68029
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-assistant-content.json
@@ -0,0 +1 @@
+{"id":"part-2","payload":{"tokens":[1,2]},"type":"future-content"}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-session-message.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-session-message.json
new file mode 100644
index 00000000..4898279b
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-session-message.json
@@ -0,0 +1 @@
+{"id":"message-2","future":{"enabled":true},"type":"future-message"}
diff --git a/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-tool-state.json b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-tool-state.json
new file mode 100644
index 00000000..63d46ada
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Fixtures/Serialization/unknown-tool-state.json
@@ -0,0 +1 @@
+{"details":{"reason":"approval"},"status":"paused"}
diff --git a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj
index 87c92715..935638c3 100644
--- a/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj
+++ b/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj
@@ -3,8 +3,11 @@
Exe
net8.0;net9.0;$(DefaultTargetFramework)
+ false
net472;$(TargetFrameworks)
+
+ App.net472.config
@@ -20,4 +23,8 @@
+
+
+
+
diff --git a/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs
new file mode 100644
index 00000000..ab7fbe3b
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Serialization/OpenCodeJsonContextTests.cs
@@ -0,0 +1,170 @@
+using System.Text.Json;
+using OpenCode.Sdk.Models;
+using OpenCode.Sdk.Tests.Support;
+
+namespace OpenCode.Sdk.Tests.Serialization;
+
+public sealed class OpenCodeJsonContextTests
+{
+ private readonly FixtureLoader _fixtures = new();
+ private readonly GeneratedJsonSerializer _serializer = new();
+
+ [Test]
+ public async Task Deserialize_Should_Create_Known_Outer_Variant_When_Marker_Is_Last()
+ {
+ var json = _fixtures.LoadJson("Serialization.known-session-message.json");
+
+ var result = _serializer.Deserialize(json);
+
+ await Assert.That(result).IsTypeOf();
+ var user = (SessionMessageUser)result;
+ await Assert.That(user.ID).IsEqualTo("message-1");
+ await Assert.That(user.Text).IsEqualTo("hello");
+ var serialized = _serializer.Serialize(user);
+ using var document = JsonDocument.Parse(serialized);
+ await Assert.That(document.RootElement.GetProperty("type").GetString()).IsEqualTo("user");
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Preserve_Unknown_Outer_Variant_Byte_For_Byte()
+ {
+ var json = _fixtures.LoadJson("Serialization.unknown-session-message.json");
+
+ var result = _serializer.Deserialize(json);
+
+ await Assert.That(result).IsTypeOf();
+ var unknown = (UnknownSessionMessage)result;
+ await Assert.That(unknown.Type).IsEqualTo("future-message");
+ var roundTrip = _serializer.Serialize(unknown);
+ await Assert.That(roundTrip).IsEqualTo(json);
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Create_Known_Assistant_Content_When_Marker_Is_Last()
+ {
+ var json = _fixtures.LoadJson("Serialization.known-assistant-content.json");
+
+ var result = _serializer.Deserialize(json);
+
+ await Assert.That(result).IsTypeOf();
+ var text = (SessionMessageAssistantText)result;
+ await Assert.That(text.Text).IsEqualTo("answer");
+ var serialized = _serializer.Serialize(text);
+ using var document = JsonDocument.Parse(serialized);
+ await Assert.That(document.RootElement.GetProperty("type").GetString()).IsEqualTo("text");
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Preserve_Unknown_Assistant_Content_Semantically()
+ {
+ var json = _fixtures.LoadJson("Serialization.unknown-assistant-content.json");
+
+ var result = _serializer.Deserialize(json);
+
+ await Assert.That(result).IsTypeOf();
+ var unknown = (UnknownSessionMessageAssistantContent)result;
+ var roundTrip = _serializer.Serialize(unknown);
+ using var expected = JsonDocument.Parse(json);
+ using var actual = JsonDocument.Parse(roundTrip);
+ await Assert.That(JsonElement.DeepEquals(expected.RootElement, actual.RootElement)).IsTrue();
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Create_Known_Tool_State_When_Marker_Is_Last()
+ {
+ var json = _fixtures.LoadJson("Serialization.known-tool-state.json");
+
+ var result = _serializer.Deserialize(json);
+
+ await Assert.That(result).IsTypeOf();
+ var pending = (SessionMessageToolStatePending)result;
+ await Assert.That(pending.Input).IsEqualTo("queued input");
+ var serialized = _serializer.Serialize(pending);
+ using var document = JsonDocument.Parse(serialized);
+ await Assert.That(document.RootElement.GetProperty("status").GetString()).IsEqualTo("pending");
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Preserve_Unknown_Tool_State_Byte_For_Byte()
+ {
+ var json = _fixtures.LoadJson("Serialization.unknown-tool-state.json");
+
+ var result = _serializer.Deserialize(json);
+
+ await Assert.That(result).IsTypeOf();
+ var unknown = (UnknownSessionMessageToolState)result;
+ await Assert.That(unknown.Status).IsEqualTo("paused");
+ var roundTrip = _serializer.Serialize(unknown);
+ await Assert.That(roundTrip).IsEqualTo(json);
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Throw_When_Union_Marker_Is_Missing()
+ {
+ var json = _fixtures.LoadJson("Serialization.missing-assistant-marker.json");
+
+ _ = await Assert.That(() => _serializer.Deserialize(json))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Throw_When_Union_Marker_Has_The_Wrong_Type()
+ {
+ var json = _fixtures.LoadJson("Serialization.malformed-assistant-marker.json");
+
+ _ = await Assert.That(() => _serializer.Deserialize(json))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Reject_Explicit_Null_For_Optional_Nonnull_Collections()
+ {
+ var json = _fixtures.LoadJson("Serialization.null-optional-collection.json");
+
+ _ = await Assert.That(() => _serializer.Deserialize(json))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Deserialize_Should_Throw_JsonException_When_Union_Payload_Is_Not_An_Object()
+ {
+ var json = _fixtures.LoadJson("Serialization.non-object-assistant-payload.json");
+
+ _ = await Assert.That(() => _serializer.Deserialize(json))
+ .Throws();
+ }
+
+ [Test]
+ public async Task ProviderMetadata_Should_Recursively_Copy_Collections()
+ {
+ var inner = new Dictionary(StringComparer.Ordinal)
+ {
+ ["original"] = default,
+ };
+ var outer = new Dictionary>(StringComparer.Ordinal)
+ {
+ ["provider"] = inner,
+ };
+ var model = new SessionMessageAssistantReasoning
+ {
+ ID = "part-1",
+ Text = "reasoning",
+ ProviderMetadata = outer,
+ };
+
+ inner.Add("inner-mutation", default);
+ outer.Add("outer-mutation", new Dictionary(StringComparer.Ordinal));
+
+ await Assert.That(model.ProviderMetadata.ContainsKey("outer-mutation")).IsFalse();
+ await Assert.That(model.ProviderMetadata["provider"].ContainsKey("inner-mutation")).IsFalse();
+ }
+
+ [Test]
+ public async Task GetTypeInfo_Should_Resolve_Union_Metadata_Without_Reflection_Fallback()
+ {
+ await Assert.That(JsonSerializer.IsReflectionEnabledByDefault).IsFalse();
+ await Assert.That(_serializer.GetTypeInfo(typeof(SessionMessage))).IsNotNull();
+ await Assert.That(_serializer.GetTypeInfo(typeof(SessionMessageAssistantContent))).IsNotNull();
+ await Assert.That(_serializer.GetTypeInfo(typeof(SessionMessageToolState))).IsNotNull();
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tests/SmokeTests.cs b/tests/OpenCode.Sdk.Tests/SmokeTests.cs
deleted file mode 100644
index 478e87d2..00000000
--- a/tests/OpenCode.Sdk.Tests/SmokeTests.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace OpenCode.Sdk.Tests;
-
-public sealed class SmokeTests
-{
- [Test]
- public async Task TestInfrastructure_Should_Run_On_Every_Target_Framework()
- {
- // Placeholder until the first real SDK code lands; keeps the TUnit +
- // Microsoft.Testing.Platform pipeline exercised on every TFM and OS leg.
- await Assert.That(Environment.Version).IsNotNull();
- }
-}
diff --git a/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs b/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs
new file mode 100644
index 00000000..d02ea2a4
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Support/FixtureLoader.cs
@@ -0,0 +1,22 @@
+using System.Reflection;
+using System.Text;
+
+namespace OpenCode.Sdk.Tests.Support;
+
+internal sealed class FixtureLoader
+{
+ private const string ResourcePrefix = "OpenCode.Sdk.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.Tests/Support/GeneratedJsonSerializer.cs b/tests/OpenCode.Sdk.Tests/Support/GeneratedJsonSerializer.cs
new file mode 100644
index 00000000..b7945917
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tests/Support/GeneratedJsonSerializer.cs
@@ -0,0 +1,45 @@
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using OpenCode.Sdk.Models;
+
+namespace OpenCode.Sdk.Tests.Support;
+
+internal sealed class GeneratedJsonSerializer
+{
+ private const string ContextTypeName = "OpenCode.Sdk.Internal.Serialization.OpenCodeJsonContext";
+ private readonly JsonSerializerContext _context = LoadContext();
+
+ public T Deserialize(string json)
+ where T : class
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(json);
+ return JsonSerializer.Deserialize(json, GetTypeInfo(typeof(T))) as T
+ ?? throw new JsonException($"The {typeof(T).Name} fixture deserialized to null.");
+ }
+
+ public string Serialize(T value)
+ where T : class
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ return JsonSerializer.Serialize(value, GetTypeInfo(typeof(T)));
+ }
+
+ public JsonTypeInfo GetTypeInfo(Type type)
+ {
+ ArgumentNullException.ThrowIfNull(type);
+ return _context.GetTypeInfo(type)
+ ?? throw new InvalidOperationException($"The generated JSON context has no metadata for {type.Name}.");
+ }
+
+ private static JsonSerializerContext LoadContext()
+ {
+ var contextType = typeof(SessionMessage).Assembly.GetType(ContextTypeName, throwOnError: true, ignoreCase: false)
+ ?? throw new InvalidOperationException($"The generated JSON context '{ContextTypeName}' was not found.");
+ var defaultProperty = contextType.GetProperty("Default", BindingFlags.Public | BindingFlags.Static)
+ ?? throw new InvalidOperationException("The generated JSON context has no Default property.");
+ return defaultProperty.GetValue(null) as JsonSerializerContext
+ ?? throw new InvalidOperationException("The generated JSON context Default property returned no context.");
+ }
+}
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
new file mode 100644
index 00000000..c0235006
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/unknown-curation-field.json
@@ -0,0 +1,10 @@
+{
+ "groups": {
+ "health": {
+ "placement": "root"
+ }
+ },
+ "envelopePayloadNames": {},
+ "propertyOverrides": [],
+ "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
new file mode 100644
index 00000000..4eb66d7a
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Fixtures/Binding/valid-curation.json
@@ -0,0 +1,9 @@
+{
+ "groups": {
+ "health": {
+ "placement": "root"
+ }
+ },
+ "envelopePayloadNames": {},
+ "propertyOverrides": []
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs
new file mode 100644
index 00000000..f9827c19
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/CurationLoaderTests.cs
@@ -0,0 +1,44 @@
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Tests.Support;
+using Testably.Abstractions.Testing;
+using Testably.Abstractions.Testing.Initializer;
+
+namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;
+
+public sealed class CurationLoaderTests
+{
+ private const string CurationPath = "tools/curation.json";
+
+ [Test]
+ public async Task LoadAsync_Should_Read_Strict_Curation()
+ {
+ var fileSystem = CreateFileSystem("Binding.valid-curation.json");
+
+ var curation = await new CurationLoader(fileSystem).LoadAsync(CurationPath, CancellationToken.None);
+
+ await Assert.That(curation.Groups["health"].Placement).IsEqualTo(GroupPlacement.Root);
+ await Assert.That(curation.EnvelopePayloadNames).IsEmpty();
+ await Assert.That(curation.PropertyOverrides).IsEmpty();
+ }
+
+ [Test]
+ public async Task LoadAsync_Should_Refuse_Unknown_Fields()
+ {
+ var fileSystem = CreateFileSystem("Binding.unknown-curation-field.json");
+
+ var exception = await Assert
+ .That(async () => _ = await new CurationLoader(fileSystem).LoadAsync(CurationPath, CancellationToken.None))
+ .Throws();
+
+ await Assert.That(exception!.Errors.Single().Category).IsEqualTo(BindingErrorCategory.Curation);
+ await Assert.That(exception.Errors.Single().Problem).Contains("mystery");
+ }
+
+ private static MockFileSystem CreateFileSystem(string fixtureName)
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize().With(new FileDescription(CurationPath, new FixtureLoader().Load(fixtureName)));
+ return fileSystem;
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationSelectionLoaderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationSelectionLoaderTests.cs
new file mode 100644
index 00000000..3bbcf2c7
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationSelectionLoaderTests.cs
@@ -0,0 +1,51 @@
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using Testably.Abstractions.Testing;
+using Testably.Abstractions.Testing.Initializer;
+
+namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;
+
+public sealed class OperationSelectionLoaderTests
+{
+ private const string ProfilePath = "tools/generation-profile.txt";
+
+ [Test]
+ public async Task LoadAsync_Should_Preserve_Operation_Order()
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize().With(new FileDescription(ProfilePath, "v2.health.get\nv2.session.message\n"));
+
+ var selection = await new OperationSelectionLoader(fileSystem).LoadAsync(ProfilePath, CancellationToken.None);
+
+ await Assert.That(selection.OperationIds).Count().IsEqualTo(2);
+ await Assert.That(selection.OperationIds[0]).IsEqualTo("v2.health.get");
+ await Assert.That(selection.OperationIds[1]).IsEqualTo("v2.session.message");
+ }
+
+ [Test]
+ public async Task LoadAsync_Should_Refuse_Duplicate_Operation_IDs()
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize().With(new FileDescription(ProfilePath, "v2.health.get\nv2.health.get\n"));
+
+ var exception = await Assert
+ .That(async () => _ = await new OperationSelectionLoader(fileSystem).LoadAsync(ProfilePath, CancellationToken.None))
+ .Throws();
+
+ await Assert.That(exception!.Errors.Single().Category).IsEqualTo(BindingErrorCategory.Selection);
+ await Assert.That(exception.Errors.Single().Problem).Contains("duplicate");
+ }
+
+ [Test]
+ public async Task LoadAsync_Should_Refuse_Blank_Lines()
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize().With(new FileDescription(ProfilePath, "v2.health.get\n\nv2.session.message\n"));
+
+ var exception = await Assert
+ .That(async () => _ = await new OperationSelectionLoader(fileSystem).LoadAsync(ProfilePath, CancellationToken.None))
+ .Throws();
+
+ await Assert.That(exception!.Errors.Single().Problem).Contains("blank");
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs
new file mode 100644
index 00000000..4d96acf4
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/SpecBinderTests.cs
@@ -0,0 +1,274 @@
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using OpenCode.Sdk.Tools.Tests.Support;
+using Testably.Abstractions;
+
+namespace OpenCode.Sdk.Tools.Tests.Generator.Binding;
+
+public sealed class SpecBinderTests
+{
+ private static readonly string[] ExpectedErrorTypeNames =
+ [
+ "InvalidRequestError",
+ "MessageNotFoundError",
+ "SessionNotFoundError",
+ "UnauthorizedError",
+ ];
+
+ [Test]
+ public async Task Bind_Should_Create_The_Selected_Pinned_Emit_Plan()
+ {
+ var (document, selection, curation) = await LoadPinnedInputsAsync();
+
+ var plan = new BindingTestHost().Bind(document, selection, curation);
+
+ await Assert.That(plan.SelectedOperationIds.SequenceEqual(selection.OperationIds, StringComparer.Ordinal)).IsTrue();
+ await Assert.That(plan.PendingOperations.Any(static operation => operation.Surface == SpecSurface.Modern)).IsTrue();
+ await Assert.That(plan.PendingOperations.Any(static operation => operation.Surface == SpecSurface.Legacy)).IsTrue();
+ await Assert.That(plan.Models.Any(static model => model.Name == "PromptFileAttachment")).IsTrue();
+ await Assert.That(plan.Models.Any(static model => model.Name == "Config")).IsFalse();
+
+ var promptFile = plan.Models.OfType().Single(static model => model.Name == "PromptFileAttachment");
+ var promptUri = promptFile.Properties.Single(static property => property.WireName == "uri").Type;
+ await Assert.That(promptUri).IsTypeOf();
+ await Assert.That(((NamedTypeReferencePlan)promptUri).Name).IsEqualTo("Uri");
+
+ var toolFile = plan.Models.OfType().Single(static model => model.Name == "ToolFileContent");
+ var toolUri = toolFile.Properties.Single(static property => property.WireName == "uri").Type;
+ await Assert.That(toolUri).IsTypeOf();
+ await Assert.That(((NamedTypeReferencePlan)toolUri).Name).IsEqualTo("Uri");
+
+ var sessionMessage = plan.Unions.Single(static union => union.Name == "SessionMessage");
+ await Assert.That(sessionMessage.MarkerWireName).IsEqualTo("type");
+ await Assert.That(sessionMessage.Variants).Count().IsEqualTo(8);
+ await Assert.That(plan.Unions.Any(static union => union.Name == "SessionMessageAssistantContent")).IsTrue();
+ await Assert.That(plan.Unions.Any(static union => union.Name == "SessionMessageToolState")).IsTrue();
+ await Assert.That(plan.Unions.Any(static union => union.Name == "LlmToolContent")).IsTrue();
+
+ var errors = plan.Unions.Single(static union => union.Name == "OpenCodeError");
+ await Assert.That(errors.Variants.Select(static variant => variant.TypeName).Order(StringComparer.Ordinal)
+ .SequenceEqual(ExpectedErrorTypeNames, StringComparer.Ordinal)).IsTrue();
+ }
+
+ [Test]
+ public async Task Bind_Should_Be_Deterministic_For_The_Selected_Pin()
+ {
+ var (document, selection, curation) = await LoadPinnedInputsAsync();
+ var host = new BindingTestHost();
+
+ var first = host.Bind(document, selection, curation);
+ var second = host.Bind(document, selection, curation);
+
+ await Assert.That(first.Models.Select(static model => model.Name)
+ .SequenceEqual(second.Models.Select(static model => model.Name), StringComparer.Ordinal)).IsTrue();
+ await Assert.That(first.Unions.Select(static union => union.Name)
+ .SequenceEqual(second.Unions.Select(static union => union.Name), StringComparer.Ordinal)).IsTrue();
+ await Assert.That(first.Registry.TypeNames.SequenceEqual(second.Registry.TypeNames, StringComparer.Ordinal)).IsTrue();
+ }
+
+ [Test]
+ public async Task Bind_Should_Report_Selection_And_Curation_Errors_Together()
+ {
+ var document = await IngestAsync(SpecScenario.Define(spec => spec
+ .WithOperation("v2.health.get")));
+ var selection = Selection("v2.health.get", "v2.missing.get");
+ var curation = Curation(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["health"] = RootGroup(),
+ },
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["v2.orphan.get"] = "Orphan",
+ });
+
+ var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(document, selection, curation));
+
+ await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Selection)).IsTrue();
+ await Assert.That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Curation)).IsTrue();
+ }
+
+ [Test]
+ public async Task Bind_Should_Refuse_Structural_Union_In_Selected_Closure()
+ {
+ var document = await IngestAsync(SpecScenario.Define(spec => spec
+ .WithSchema("Choice", schema => schema.AnyOf(
+ branch => branch.Type("string"),
+ branch => branch.Type("boolean")))
+ .WithOperation("v2.health.get", configure: operation => operation
+ .Response(200, "application/json", schema => schema.Ref("Choice")))));
+
+ var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(
+ document,
+ Selection("v2.health.get"),
+ Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), })));
+
+ await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Schema).Problem)
+ .Contains("structural union");
+ }
+
+ [Test]
+ public async Task Bind_Should_Require_Selected_Envelope_Payload_Name()
+ {
+ var document = await IngestAsync(SpecScenario.Define(spec => spec
+ .WithOperation("v2.session.message", path: "/api/session/{sessionID}/message", configure: operation => operation
+ .Parameter("sessionID", "path", schema => schema.Type("string"), required: true)
+ .Response(200, "application/json", schema => schema.Type("object")
+ .Property("data", property => property.Type("string"), required: true)))));
+ var groups = new Dictionary(StringComparer.Ordinal)
+ {
+ ["session"] = ClientGroup(),
+ };
+
+ var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(
+ document,
+ Selection("v2.session.message"),
+ Curation(groups)));
+
+ await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem)
+ .Contains("payload name");
+ }
+
+ [Test]
+ public async Task Bind_Should_Refuse_Operation_Curation_For_Pending_Operation()
+ {
+ var document = await IngestAsync(SpecScenario.Define(spec => spec
+ .WithOperation("v2.health.get")
+ .WithOperation("v2.session.message", path: "/api/session/message", configure: operation => operation
+ .Response(200, "application/json", schema => schema.Type("object")
+ .Property("data", property => property.Type("string"), required: true)))));
+ var envelopeNames = new Dictionary(StringComparer.Ordinal)
+ {
+ ["v2.session.message"] = "Message",
+ };
+
+ var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(
+ document,
+ Selection("v2.health.get"),
+ Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }, envelopeNames)));
+
+ await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem)
+ .Contains("not selected");
+ }
+
+ [Test]
+ public async Task Bind_Should_Collapse_Duplicate_References_Into_Semantic_Type_Plans()
+ {
+ var document = await IngestAsync(SpecScenario.Define(spec => spec
+ .WithSchema("Shared", schema => schema.Type("object")
+ .Property("value", property => property.Type("string"), required: true))
+ .WithSchema("Container", schema => schema.Type("object")
+ .Property("first", property => property.Ref("Shared"), required: true)
+ .Property("items", property => property.Type("array").Items(item => item.Ref("Shared")), required: true))
+ .WithOperation("v2.health.get", configure: operation => operation
+ .Response(200, "application/json", schema => schema.Ref("Container")))));
+
+ var plan = new BindingTestHost().Bind(
+ document,
+ Selection("v2.health.get"),
+ Curation(new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), }));
+
+ await Assert.That(plan.Models.Count(static model => model.Name == "Shared")).IsEqualTo(1);
+ var container = plan.Models.OfType().Single(static model => model.Name == "Container");
+ var items = container.Properties.Single(static property => property.WireName == "items").Type;
+ await Assert.That(items).IsTypeOf();
+ await Assert.That(((ListTypeReferencePlan)items).ElementType).IsTypeOf();
+ await Assert.That(((NamedTypeReferencePlan)((ListTypeReferencePlan)items).ElementType).Name).IsEqualTo("Shared");
+ }
+
+ [Test]
+ public async Task Bind_Should_Require_Curation_For_Every_Selected_Group()
+ {
+ var document = await IngestAsync(SpecScenario.Define(spec => spec.WithOperation("v2.health.get")));
+
+ var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(
+ document,
+ Selection("v2.health.get"),
+ Curation(new Dictionary(StringComparer.Ordinal))));
+
+ await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem)
+ .Contains("no curation row");
+ }
+
+ [Test]
+ public async Task Bind_Should_Report_Duplicate_Property_Overrides_As_Binding_Errors()
+ {
+ var document = await IngestAsync(SpecScenario.Define(spec => spec
+ .WithSchema("Resource", schema => schema.Type("object")
+ .Property("uri", property => property.Type("string"), required: true))
+ .WithOperation("v2.health.get", configure: operation => operation
+ .Response(200, "application/json", schema => schema.Ref("Resource")))));
+ var curation = Curation(
+ new Dictionary(StringComparer.Ordinal) { ["health"] = RootGroup(), },
+ propertyOverrides: [UriOverride("Resource", "uri"), UriOverride("Resource", "uri")]);
+
+ var exception = Assert.Throws(() => _ = new BindingTestHost().Bind(
+ document,
+ Selection("v2.health.get"),
+ curation));
+
+ await Assert.That(exception.Errors.Single(static error => error.Category == BindingErrorCategory.Curation).Problem)
+ .Contains("duplicated");
+ }
+
+ private static async Task<(SpecDocument Document, OperationSelection Selection, GenerationCuration Curation)> LoadPinnedInputsAsync()
+ {
+ var fileSystem = new RealFileSystem();
+ var fixtureRoot = fileSystem.Path.Combine(AppContext.BaseDirectory, "Fixtures");
+ var document = await new SpecIngestion(fileSystem)
+ .IngestAsync(fileSystem.Path.Combine(fixtureRoot, "openapi.json"), CancellationToken.None);
+ var selection = await new OperationSelectionLoader(fileSystem)
+ .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "generation-profile.txt"), CancellationToken.None);
+ var curation = await new CurationLoader(fileSystem)
+ .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "curation.json"), CancellationToken.None);
+ return (document, selection, curation);
+ }
+
+ private static async Task IngestAsync(SpecScenario scenario)
+ {
+ var context = scenario.Build();
+ return await new SpecIngestion(context.FileSystem).IngestAsync(context.SpecPath, CancellationToken.None);
+ }
+
+ private static OperationSelection Selection(params string[] operationIds) =>
+ new()
+ {
+ OperationIds = operationIds,
+ };
+
+ private static GenerationCuration Curation(
+ IReadOnlyDictionary groups,
+ IReadOnlyDictionary? envelopePayloadNames = null,
+ IReadOnlyList? propertyOverrides = null) =>
+ new()
+ {
+ Groups = groups,
+ EnvelopePayloadNames = envelopePayloadNames ?? new Dictionary(StringComparer.Ordinal),
+ PropertyOverrides = propertyOverrides ?? [],
+ };
+
+ private static PropertyOverride UriOverride(string schema, string property) =>
+ new()
+ {
+ Schema = schema,
+ Property = property,
+ Type = PropertyOverrideType.Uri,
+ Reason = "The fixture omits format: uri.",
+ };
+
+ private static GroupCuration RootGroup() =>
+ new()
+ {
+ Placement = GroupPlacement.Root,
+ };
+
+ private static GroupCuration ClientGroup() =>
+ new()
+ {
+ Placement = GroupPlacement.Client,
+ ClientName = "Sessions",
+ HandleName = "SessionClient",
+ };
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.cs
new file mode 100644
index 00000000..4d958ab0
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/ModelEmitterTests.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 ModelEmitterTests
+{
+ [Test]
+ public async Task Emit_Should_Produce_Immutable_Annotated_Models()
+ {
+ var sources = ModelEmitter.Emit(EmitterPlanFixture.CreateModelSnapshot());
+
+ await Verify(EmitterSnapshot.Create(sources));
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/RegistryEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/RegistryEmitterTests.cs
new file mode 100644
index 00000000..8e6b032d
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/RegistryEmitterTests.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 RegistryEmitterTests
+{
+ [Test]
+ public async Task Emit_Should_Produce_The_Source_Generated_Context_Registry()
+ {
+ var sources = RegistryEmitter.Emit(EmitterPlanFixture.CreateRegistry());
+
+ await Verify(EmitterSnapshot.Create(sources));
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/SourceEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/SourceEmitterTests.cs
new file mode 100644
index 00000000..714f7411
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/SourceEmitterTests.cs
@@ -0,0 +1,42 @@
+using OpenCode.Sdk.Tools.Generator.Emission;
+using OpenCode.Sdk.Tools.Tests.Support;
+
+namespace OpenCode.Sdk.Tools.Tests.Generator.Emission;
+
+public sealed class SourceEmitterTests
+{
+ [Test]
+ public async Task Emit_Should_Return_Ordinal_Sorted_Byte_Identical_Source()
+ {
+ var plan = EmitterPlanFixture.Create();
+
+ var first = SourceEmitter.Emit(plan);
+ var second = SourceEmitter.Emit(plan);
+
+ await Assert.That(first.Select(static source => source.RelativePath)
+ .SequenceEqual(first.Select(static source => source.RelativePath).Order(StringComparer.Ordinal), StringComparer.Ordinal)).IsTrue();
+ await Assert.That(first.Select(static source => source.Utf8Source.ToArray())
+ .Zip(second.Select(static source => source.Utf8Source.ToArray()), static (left, right) => left.SequenceEqual(right))
+ .All(static equal => equal)).IsTrue();
+ }
+
+ [Test]
+ public async Task Emit_Should_Produce_Compilable_Source()
+ {
+ var sources = SourceEmitter.Emit(EmitterPlanFixture.Create());
+
+ var diagnostics = GeneratedSourceCompiler.Compile(sources);
+
+ await Assert.That(diagnostics).IsEmpty();
+ }
+
+ [Test]
+ public async Task Emit_Should_Produce_Compilable_Source_For_The_Selected_Pin()
+ {
+ var plan = await new BindingTestHost().BindPinnedAsync();
+
+ var diagnostics = GeneratedSourceCompiler.Compile(SourceEmitter.Emit(plan));
+
+ await Assert.That(diagnostics).IsEmpty();
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.cs
new file mode 100644
index 00000000..3d705aa1
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Emission/UnionEmitterTests.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 UnionEmitterTests
+{
+ [Test]
+ public async Task Emit_Should_Produce_Tolerant_Marked_Union_Converters()
+ {
+ var sources = UnionEmitter.Emit(EmitterPlanFixture.CreateUnionSnapshot());
+
+ await Verify(EmitterSnapshot.Create(sources));
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/ModuleInitializer.cs b/tests/OpenCode.Sdk.Tools.Tests/ModuleInitializer.cs
new file mode 100644
index 00000000..7ee575c4
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/ModuleInitializer.cs
@@ -0,0 +1,12 @@
+using System.Runtime.CompilerServices;
+
+namespace OpenCode.Sdk.Tools.Tests;
+
+internal static class ModuleInitializer
+{
+ [ModuleInitializer]
+ public static void Initialize()
+ {
+ UseProjectRelativeDirectory("Snapshots");
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj b/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj
index f8753874..78646c8c 100644
--- a/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj
+++ b/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj
@@ -21,6 +21,12 @@
+
+
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs
new file mode 100644
index 00000000..a32711e1
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Output/GenerationWriterTests.cs
@@ -0,0 +1,215 @@
+using System.Text.Json;
+using OpenCode.Sdk.Tools.Output;
+using OpenCode.Sdk.Tools.Tests.Support;
+using Testably.Abstractions.Testing;
+using Testably.Abstractions.Testing.Initializer;
+
+namespace OpenCode.Sdk.Tools.Tests.Output;
+
+public sealed class GenerationWriterTests
+{
+ [Test]
+ public async Task WriteAsync_Should_Refuse_Unsafe_Source_Paths()
+ {
+ var fileSystem = GenerationTestData.CreateFileSystem();
+ 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,
+ CancellationToken.None))
+ .Throws();
+
+ await Assert.That(exception!.Message).Contains("unsafe");
+ }
+
+ [Test]
+ public async Task WriteAsync_Should_Refuse_Unsafe_Manifest_Paths()
+ {
+ var fileSystem = GenerationTestData.CreateFileSystem();
+ fileSystem.Initialize().With(new FileDescription(
+ GenerationTestData.ManifestPath,
+ "{\"files\":[\"../outside.cs\"]}"));
+ 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,
+ CancellationToken.None))
+ .Throws();
+
+ await Assert.That(exception!.Message).Contains("unsafe");
+ }
+
+ [Test]
+ public async Task WriteAsync_Should_Refuse_Symbolic_Links_In_Owned_Paths()
+ {
+ const string outsideRoot = "outside";
+ var fileSystem = GenerationTestData.CreateFileSystem();
+ fileSystem.Directory.CreateDirectory(outsideRoot);
+ _ = fileSystem.Directory.CreateSymbolicLink(
+ fileSystem.Path.Combine(GenerationTestData.OutputRoot, "Models"),
+ outsideRoot);
+ 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,
+ CancellationToken.None))
+ .Throws();
+
+ await Assert.That(exception!.Message).Contains("symbolic link");
+ }
+
+ [Test]
+ public async Task WriteAsync_Should_Refuse_Case_Only_Owned_Path_Changes()
+ {
+ const string oldPath = "Models/Widget.cs";
+ var fileSystem = GenerationTestData.CreateFileSystem();
+ var manifest = JsonSerializer.Serialize(new GenerationManifest { Files = [oldPath], });
+ fileSystem.Initialize().With(
+ new FileDescription(GenerationTestData.ManifestPath, manifest),
+ new FileDescription(fileSystem.Path.Combine(GenerationTestData.OutputRoot, oldPath), "old"));
+ 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,
+ CancellationToken.None))
+ .Throws();
+
+ await Assert.That(exception!.Message).Contains("only by case");
+ }
+
+ [Test]
+ public async Task WriteAsync_Should_Delete_Only_Stale_Manifest_Files()
+ {
+ const string stalePath = "src/OpenCode.Sdk/Models/Stale.cs";
+ const string unrelatedPath = "src/OpenCode.Sdk/Manual.cs";
+ var fileSystem = GenerationTestData.CreateFileSystem();
+ var manifest = JsonSerializer.Serialize(new GenerationManifest { Files = ["Models/Stale.cs"], });
+ fileSystem.Initialize().With(
+ new FileDescription(GenerationTestData.ManifestPath, manifest),
+ new FileDescription(stalePath, "stale"),
+ new FileDescription(unrelatedPath, "manual"));
+ 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,
+ CancellationToken.None);
+
+ await Assert.That(fileSystem.File.Exists(stalePath)).IsFalse();
+ await Assert.That(fileSystem.File.Exists(unrelatedPath)).IsTrue();
+ await Assert.That(result.DeletedPaths).Contains("Models/Stale.cs");
+ await Assert.That(result.CreatedPaths).Contains("Models/Fresh.cs");
+ }
+
+ [Test]
+ public async Task WriteAsync_Should_Be_Idempotent_After_Project_Formatting()
+ {
+ const string relativePath = "Models/Example.cs";
+ var fileSystem = GenerationTestData.CreateFileSystem();
+ var formatter = new RecordingProjectFormatter(fileSystem, async (currentFileSystem, _, _, cancellationToken) =>
+ {
+ var path = currentFileSystem.Path.Combine(GenerationTestData.OutputRoot, relativePath);
+ var source = await currentFileSystem.File.ReadAllTextAsync(path, cancellationToken);
+ await currentFileSystem.File.WriteAllTextAsync(
+ path,
+ source.Replace("public record", "public sealed record", StringComparison.Ordinal),
+ cancellationToken);
+ });
+ 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 Assert.That(formatter.CallCount).IsEqualTo(2);
+ await Assert.That(formatter.SourcePaths).Contains(relativePath);
+ await Assert.That(verify.HasChanges).IsFalse();
+ }
+
+ [Test]
+ public async Task WriteAsync_Should_Report_And_Repair_Verify_Drift()
+ {
+ const string relativePath = "Models/Example.cs";
+ 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);
+ var outputPath = fileSystem.Path.Combine(GenerationTestData.OutputRoot, relativePath);
+ await fileSystem.File.WriteAllTextAsync(outputPath, "drift\n", CancellationToken.None);
+
+ var result = await writer.WriteAsync(
+ GenerationTestData.OutputRoot,
+ GenerationTestData.ProjectPath,
+ sources,
+ partialMarkerContent: null,
+ verify: true,
+ CancellationToken.None);
+
+ await Assert.That(result.HasChanges).IsTrue();
+ await Assert.That(result.ChangedPaths).Contains(relativePath);
+ var repaired = await fileSystem.File.ReadAllTextAsync(outputPath, CancellationToken.None);
+ await Assert.That(repaired).IsEqualTo("expected\n");
+ }
+
+ [Test]
+ public async Task WriteAsync_Should_Create_And_Remove_The_Partial_Marker()
+ {
+ var fileSystem = GenerationTestData.CreateFileSystem();
+ var writer = new GenerationWriter(fileSystem, new RecordingProjectFormatter(fileSystem));
+
+ _ = await writer.WriteAsync(
+ GenerationTestData.OutputRoot,
+ GenerationTestData.ProjectPath,
+ [],
+ "Pending modern operations: 1.\nPending legacy operations: 2.\n",
+ verify: false,
+ CancellationToken.None);
+ var removal = await writer.WriteAsync(
+ GenerationTestData.OutputRoot,
+ GenerationTestData.ProjectPath,
+ [],
+ partialMarkerContent: null,
+ verify: false,
+ CancellationToken.None);
+
+ await Assert.That(fileSystem.File.Exists(GenerationTestData.MarkerPath)).IsFalse();
+ await Assert.That(removal.DeletedPaths).Contains(".generation-incomplete");
+ }
+}
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
new file mode 100644
index 00000000..6a3f9913
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ModelEmitterTests.Emit_Should_Produce_Immutable_Annotated_Models.verified.txt
@@ -0,0 +1,104 @@
+// File: Models/BadRequestError.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;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents a rejected request.
+///
+public sealed record BadRequestError : OpenCodeError
+{
+ ///
+ /// Gets the tag value.
+ ///
+ [JsonPropertyName("_tag")]
+ public override string Tag => "BadRequestError";
+
+ ///
+ /// Gets the error message.
+ ///
+ [JsonPropertyName("message")]
+ public required string Message { get; init; }
+}
+
+// File: Models/ExampleItem.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.Collections.ObjectModel;
+using System.Linq;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents one emitted item.
+///
+public sealed record ExampleItem
+{
+ ///
+ /// Gets the item identifier.
+ ///
+ [JsonPropertyName("id")]
+ public required string ID { get; init; }
+
+ ///
+ /// Gets the note value.
+ ///
+ [JsonPropertyName("note")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+
+ ///
+ /// Gets the item tags.
+ ///
+ [JsonPropertyName("tags")]
+ public IReadOnlyList Tags
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? Array.Empty() : new List(value).AsReadOnly();
+ }
+ } = Array.Empty();
+
+ ///
+ /// Gets links by relation.
+ ///
+ [JsonPropertyName("links")]
+ public IReadOnlyDictionary Links
+ {
+ get;
+ init
+ {
+ // Source-generated deserialization passes null for an absent optional init-only collection.
+ field = value is null ? new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)) : new ReadOnlyDictionary(value.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+}
+
+// File: Models/ExampleMode.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;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Defines the supported example mode values.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ExampleMode
+{
+ ///
+ /// Represents the 'fast-mode' wire value.
+ ///
+ [JsonStringEnumMemberName("fast-mode")]
+ FastMode,
+ ///
+ /// Represents the 'safe' wire value.
+ ///
+ [JsonStringEnumMemberName("safe")]
+ Safe
+}
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
new file mode 100644
index 00000000..a60d367f
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/RegistryEmitterTests.Emit_Should_Produce_The_Source_Generated_Context_Registry.verified.txt
@@ -0,0 +1,20 @@
+// File: Internal/Serialization/OpenCodeJsonContext.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;
+[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata, RespectNullableAnnotations = true)]
+[JsonSerializable(typeof(BadRequestError))]
+[JsonSerializable(typeof(CreatedEvent))]
+[JsonSerializable(typeof(DeletedEvent))]
+[JsonSerializable(typeof(ExampleEvent))]
+[JsonSerializable(typeof(ExampleItem))]
+[JsonSerializable(typeof(ExampleMode))]
+[JsonSerializable(typeof(OpenCodeError))]
+[JsonSerializable(typeof(UnknownExampleEvent))]
+[JsonSerializable(typeof(UnknownOpenCodeError))]
+internal sealed partial class OpenCodeJsonContext : JsonSerializerContext
+{
+}
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
new file mode 100644
index 00000000..7977bccd
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/UnionEmitterTests.Emit_Should_Produce_Tolerant_Marked_Union_Converters.verified.txt
@@ -0,0 +1,115 @@
+// File: Models/ExampleEvent.cs
+// Generated by OpenCode.Sdk.Tools from spec/openapi.json.
+// Do not edit by hand — change tools/curation.json or the emitters, then regenerate.
+using System.Text.Json.Serialization;
+using OpenCode.Sdk.Internal.Serialization;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Represents an example event.
+///
+[JsonConverter(typeof(ExampleEventJsonConverter))]
+public abstract record ExampleEvent
+{
+ ///
+ /// Gets the 'type' union marker.
+ ///
+ [JsonPropertyName("type")]
+ public abstract string Type { get; }
+}
+
+// File: Models/UnknownExampleEvent.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;
+
+namespace OpenCode.Sdk.Models;
+///
+/// Preserves an unknown example event payload.
+///
+public sealed record UnknownExampleEvent : ExampleEvent
+{
+ private readonly string _marker;
+ ///
+ /// Initializes an unknown union value from its marker and raw payload.
+ ///
+ [JsonConstructor]
+ public UnknownExampleEvent(string type, JsonElement payload)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(type);
+ _marker = type;
+ Payload = payload.Clone();
+ }
+
+ ///
+ /// Gets the unrecognized 'type' marker.
+ ///
+ public override string Type => _marker;
+ ///
+ /// Gets the preserved raw JSON payload.
+ ///
+ public JsonElement Payload { get; }
+}
+
+// File: Internal/Serialization/ExampleEventJsonConverter.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 ExampleEventJsonConverter : JsonConverter
+{
+ private static readonly Dictionary TypesByTag = new Dictionary(StringComparer.Ordinal)
+ {
+ ["created"] = typeof(CreatedEvent),
+ ["deleted"] = typeof(DeletedEvent)
+ };
+ public override bool HandleNull => true;
+
+ public override ExampleEvent 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)
+ 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)
+ throw new JsonException("The 'type' marker cannot be null.");
+ if (TypesByTag.TryGetValue(marker, out var targetType))
+ {
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for ExampleEvent.");
+ return JsonSerializer.Deserialize(payload, typeInfo) as ExampleEvent ?? throw new JsonException("The ExampleEvent payload deserialized to null.");
+ }
+
+ return new UnknownExampleEvent(marker, payload);
+ }
+
+ public override void Write(Utf8JsonWriter writer, ExampleEvent value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ if (value is UnknownExampleEvent unknown)
+ {
+ unknown.Payload.WriteTo(writer);
+ return;
+ }
+
+ var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for ExampleEvent.");
+ JsonSerializer.Serialize(writer, value, typeInfo);
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs
new file mode 100644
index 00000000..7e7090bc
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/BindingTestHost.cs
@@ -0,0 +1,39 @@
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using Testably.Abstractions;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal sealed class BindingTestHost
+{
+ private readonly SpecBinder _binder;
+
+ public BindingTestHost()
+ {
+ var schemaNames = new SchemaNameResolver();
+ _binder = new SpecBinder(
+ new ReachableSchemaCollector(),
+ new CurationValidator(),
+ new SchemaPlanBinder(schemaNames));
+ }
+
+ public EmitPlan Bind(SpecDocument document, OperationSelection selection, GenerationCuration curation)
+ {
+ return _binder.Bind(document, selection, curation);
+ }
+
+ public async Task BindPinnedAsync(CancellationToken cancellationToken = default)
+ {
+ var fileSystem = new RealFileSystem();
+ var fixtureRoot = fileSystem.Path.Combine(AppContext.BaseDirectory, "Fixtures");
+ var document = await new SpecIngestion(fileSystem)
+ .IngestAsync(fileSystem.Path.Combine(fixtureRoot, "openapi.json"), cancellationToken);
+ var selection = await new OperationSelectionLoader(fileSystem)
+ .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "generation-profile.txt"), cancellationToken);
+ var curation = await new CurationLoader(fileSystem)
+ .LoadAsync(fileSystem.Path.Combine(fixtureRoot, "curation.json"), cancellationToken);
+ return Bind(document, selection, curation);
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/CompilerAnalyzerAssemblyLoader.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/CompilerAnalyzerAssemblyLoader.cs
new file mode 100644
index 00000000..908027a7
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/CompilerAnalyzerAssemblyLoader.cs
@@ -0,0 +1,17 @@
+using Microsoft.CodeAnalysis;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal sealed class CompilerAnalyzerAssemblyLoader : IAnalyzerAssemblyLoader
+{
+ public void AddDependencyLocation(string fullPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
+ }
+
+ public System.Reflection.Assembly LoadFromPath(string fullPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
+ return System.Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath);
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs
new file mode 100644
index 00000000..df2f3725
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs
@@ -0,0 +1,199 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal static class EmitterPlanFixture
+{
+ public static EmitPlan Create()
+ {
+ var models = new ModelPlan[]
+ {
+ CreateExampleItem(),
+ CreateExampleMode(),
+ CreateVariant("CreatedEvent", "created", "item", "Item", Named("ExampleItem")),
+ CreateVariant("DeletedEvent", "deleted", "id", "ID", Named("string")),
+ CreateError(),
+ };
+ var unions = new[]
+ {
+ CreateExampleEvent(),
+ CreateOpenCodeError(),
+ };
+
+ return new EmitPlan
+ {
+ SelectedOperationIds = ["v2.example.get"],
+ Models = models,
+ Unions = unions,
+ Registry = new RegistryPlan
+ {
+ TypeNames =
+ [
+ "BadRequestError",
+ "CreatedEvent",
+ "DeletedEvent",
+ "ExampleEvent",
+ "ExampleItem",
+ "ExampleMode",
+ "OpenCodeError",
+ "UnknownExampleEvent",
+ "UnknownOpenCodeError",
+ ],
+ },
+ PendingOperations = [],
+ };
+ }
+
+ public static EmitPlan CreateModelSnapshot()
+ {
+ var plan = Create();
+ return plan with
+ {
+ Models =
+ [
+ plan.Models.Single(static model => model.Name == "BadRequestError"),
+ plan.Models.Single(static model => model.Name == "ExampleItem"),
+ plan.Models.Single(static model => model.Name == "ExampleMode"),
+ ],
+ Unions = [plan.Unions.Single(static union => union.Name == "OpenCodeError")],
+ };
+ }
+
+ public static IReadOnlyList CreateUnionSnapshot() => [CreateExampleEvent()];
+
+ public static RegistryPlan CreateRegistry() => Create().Registry;
+
+ private static ObjectModelPlan CreateExampleItem() =>
+ new()
+ {
+ Name = "ExampleItem",
+ Namespace = "OpenCode.Sdk.Models",
+ Description = "Represents one emitted item.",
+ Properties =
+ [
+ Property("id", "ID", Named("string"), isRequired: true, "Gets the item identifier."),
+ Property("note", "Note", Named("string", isNullable: true), isRequired: false, description: null),
+ Property("tags", "Tags", ListOf(Named("string")), isRequired: false, "Gets the item tags."),
+ Property("links", "Links", DictionaryOf(Named("Uri")), isRequired: false, "Gets links by relation."),
+ ],
+ };
+
+ private static EnumModelPlan CreateExampleMode() =>
+ new()
+ {
+ Name = "ExampleMode",
+ Namespace = "OpenCode.Sdk.Models",
+ Description = null,
+ Values =
+ [
+ new EnumValuePlan { Name = "FastMode", WireValue = "fast-mode", },
+ new EnumValuePlan { Name = "Safe", WireValue = "safe", },
+ ],
+ };
+
+ private static ObjectModelPlan CreateVariant(string name, string tag, string wireName, string propertyName,
+ TypeReferencePlan propertyType) =>
+ new()
+ {
+ Name = name,
+ Namespace = "OpenCode.Sdk.Models",
+ Description = null,
+ BaseTypeName = "ExampleEvent",
+ Properties =
+ [
+ LiteralProperty("type", "Type", tag),
+ Property(wireName, propertyName, propertyType, isRequired: true, description: null),
+ ],
+ };
+
+ private static ObjectModelPlan CreateError() =>
+ new()
+ {
+ Name = "BadRequestError",
+ Namespace = "OpenCode.Sdk.Models",
+ Description = "Represents a rejected request.",
+ BaseTypeName = "OpenCodeError",
+ Properties =
+ [
+ LiteralProperty("_tag", "Tag", "BadRequestError"),
+ Property("message", "Message", Named("string"), isRequired: true, "Gets the error message."),
+ ],
+ };
+
+ private static UnionPlan CreateExampleEvent() =>
+ new()
+ {
+ Name = "ExampleEvent",
+ Namespace = "OpenCode.Sdk.Models",
+ UnknownTypeName = "UnknownExampleEvent",
+ MarkerWireName = "type",
+ MarkerName = "Type",
+ MarkerKind = LiteralKind.String,
+ Variants =
+ [
+ new UnionVariantPlan { TypeName = "CreatedEvent", Tag = "created", },
+ new UnionVariantPlan { TypeName = "DeletedEvent", Tag = "deleted", },
+ ],
+ Description = "Represents an example event.",
+ };
+
+ private static UnionPlan CreateOpenCodeError() =>
+ new()
+ {
+ Name = "OpenCodeError",
+ Namespace = "OpenCode.Sdk.Models",
+ UnknownTypeName = "UnknownOpenCodeError",
+ MarkerWireName = "_tag",
+ MarkerName = "Tag",
+ MarkerKind = LiteralKind.String,
+ Variants = [new UnionVariantPlan { TypeName = "BadRequestError", Tag = "BadRequestError", }],
+ Description = "Represents an opencode API error.",
+ };
+
+ private static ModelPropertyPlan Property(string wireName, string name, TypeReferencePlan type, bool isRequired,
+ string? description) =>
+ new()
+ {
+ WireName = wireName,
+ Name = name,
+ Type = type,
+ IsRequired = isRequired,
+ IsLiteral = false,
+ Description = description,
+ };
+
+ private static ModelPropertyPlan LiteralProperty(string wireName, string name, string value) =>
+ new()
+ {
+ WireName = wireName,
+ Name = name,
+ Type = Named("string"),
+ IsRequired = true,
+ IsLiteral = true,
+ LiteralKind = LiteralKind.String,
+ LiteralValue = value,
+ Description = null,
+ };
+
+ private static NamedTypeReferencePlan Named(string name, bool isNullable = false) =>
+ new()
+ {
+ Name = name,
+ IsNullable = isNullable,
+ };
+
+ private static ListTypeReferencePlan ListOf(TypeReferencePlan elementType) =>
+ new()
+ {
+ ElementType = elementType,
+ IsNullable = false,
+ };
+
+ private static DictionaryTypeReferencePlan DictionaryOf(TypeReferencePlan valueType) =>
+ new()
+ {
+ ValueType = valueType,
+ IsNullable = false,
+ };
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterSnapshot.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterSnapshot.cs
new file mode 100644
index 00000000..8763e5f6
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterSnapshot.cs
@@ -0,0 +1,25 @@
+using System.Text;
+using OpenCode.Sdk.Tools.Generator.Emission;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal static class EmitterSnapshot
+{
+ public static string Create(IReadOnlyList sources)
+ {
+ ArgumentNullException.ThrowIfNull(sources);
+ var result = new StringBuilder();
+ for (var index = 0; index < sources.Count; index++)
+ {
+ var source = sources[index];
+ _ = result.Append("// File: ").Append(source.RelativePath).Append('\n');
+ _ = result.Append(Encoding.UTF8.GetString(source.Utf8Source.Span));
+ if (index < sources.Count - 1)
+ {
+ _ = result.Append('\n');
+ }
+ }
+
+ return result.ToString();
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GeneratedSourceCompiler.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GeneratedSourceCompiler.cs
new file mode 100644
index 00000000..281103a6
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GeneratedSourceCompiler.cs
@@ -0,0 +1,80 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using OpenCode.Sdk.Tools.Generator.Emission;
+using Testably.Abstractions;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal static class GeneratedSourceCompiler
+{
+ private static readonly CSharpParseOptions ParseOptions = new(LanguageVersion.CSharp14);
+ private static readonly PortableExecutableReference[] References = CreateReferences();
+ private static readonly AnalyzerFileReference SourceGenerator = CreateSourceGeneratorReference();
+
+ public static Diagnostic[] Compile(IReadOnlyList sources)
+ {
+ ArgumentNullException.ThrowIfNull(sources);
+ var syntaxTrees = sources.Select(static source => CSharpSyntaxTree.ParseText(
+ Encoding.UTF8.GetString(source.Utf8Source.Span),
+ ParseOptions,
+ source.RelativePath,
+ Encoding.UTF8));
+ var compilation = CSharpCompilation.Create(
+ "GeneratedSourceProbe",
+ syntaxTrees,
+ References,
+ new CSharpCompilationOptions(
+ OutputKind.DynamicallyLinkedLibrary,
+ nullableContextOptions: NullableContextOptions.Enable,
+ deterministic: true));
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(
+ SourceGenerator.GetGenerators(LanguageNames.CSharp),
+ parseOptions: ParseOptions);
+ _ = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var generatorDiagnostics);
+
+ return
+ [
+ .. generatorDiagnostics.Concat(outputCompilation.GetDiagnostics())
+ .Where(static diagnostic => diagnostic.Severity is DiagnosticSeverity.Warning or DiagnosticSeverity.Error),
+ ];
+ }
+
+ private static PortableExecutableReference[] CreateReferences()
+ {
+ var trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string
+ ?? throw new InvalidOperationException("The runtime did not expose trusted platform assemblies.");
+ return
+ [
+ .. trustedAssemblies
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
+ .Select(static path => MetadataReference.CreateFromFile(path)),
+ ];
+ }
+
+ private static AnalyzerFileReference CreateSourceGeneratorReference()
+ {
+ var fileSystem = new RealFileSystem();
+ var frameworkDirectory = fileSystem.Path.GetDirectoryName(typeof(JsonSerializer).Assembly.Location)
+ ?? throw new InvalidOperationException("The runtime framework directory could not be resolved.");
+ var frameworkVersion = fileSystem.Path.GetFileName(frameworkDirectory);
+ var dotnetRoot = fileSystem.Path.GetFullPath(fileSystem.Path.Combine(frameworkDirectory, "..", "..", ".."));
+ var generatorPath = fileSystem.Path.Combine(
+ dotnetRoot,
+ "packs",
+ "Microsoft.NETCore.App.Ref",
+ frameworkVersion,
+ "analyzers",
+ "dotnet",
+ "cs",
+ "System.Text.Json.SourceGeneration.dll");
+ if (!fileSystem.File.Exists(generatorPath))
+ {
+ throw new InvalidOperationException($"The System.Text.Json source generator was not found at '{generatorPath}'.");
+ }
+
+ return new AnalyzerFileReference(generatorPath, new CompilerAnalyzerAssemblyLoader());
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs
new file mode 100644
index 00000000..cac1866f
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/GenerationTestData.cs
@@ -0,0 +1,54 @@
+using System.Text;
+using OpenCode.Sdk.Tools.Generator.Emission;
+using Testably.Abstractions.Testing;
+using Testably.Abstractions.Testing.Initializer;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal static class GenerationTestData
+{
+ public const string ManifestPath = "src/OpenCode.Sdk/.generated-manifest.json";
+ public const string MarkerPath = "src/OpenCode.Sdk/.generation-incomplete";
+ public const string OutputRoot = "src/OpenCode.Sdk";
+ public const string ProjectPath = "src/OpenCode.Sdk/OpenCode.Sdk.csproj";
+
+ private const string Curation = """
+ {
+ "groups": {
+ "health": {
+ "placement": "root"
+ }
+ },
+ "envelopePayloadNames": {},
+ "propertyOverrides": []
+ }
+ """;
+
+ public static MockFileSystem CreateFileSystem()
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.Initialize().With(new FileDescription(ProjectPath, ""));
+ return fileSystem;
+ }
+
+ public static MockFileSystem CreateCommandFileSystem()
+ {
+ var spec = new SpecDocumentBuilder()
+ .WithOperation("v2.health.get", path: "/api/health")
+ .WithOperation("session.list", path: "/session")
+ .BuildJson();
+ var fileSystem = CreateFileSystem();
+ fileSystem.Initialize().With(
+ new FileDescription("spec/openapi.json", spec),
+ new FileDescription("tools/generation-profile.txt", "v2.health.get\n"),
+ new FileDescription("tools/curation.json", Curation));
+ return fileSystem;
+ }
+
+ public static GeneratedSource Source(string relativePath, string source) =>
+ new()
+ {
+ RelativePath = relativePath,
+ Utf8Source = Encoding.UTF8.GetBytes(source),
+ };
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/RecordingProjectFormatter.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/RecordingProjectFormatter.cs
new file mode 100644
index 00000000..8402a416
--- /dev/null
+++ b/tests/OpenCode.Sdk.Tools.Tests/Support/RecordingProjectFormatter.cs
@@ -0,0 +1,36 @@
+using System.IO.Abstractions;
+using OpenCode.Sdk.Tools.Output.Abstractions;
+
+namespace OpenCode.Sdk.Tools.Tests.Support;
+
+internal sealed class RecordingProjectFormatter(
+ IFileSystem fileSystem,
+ Func, CancellationToken, Task>? onFormat = null)
+ : IProjectFormatter
+{
+ private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+ private readonly Func, CancellationToken, Task>? _onFormat = onFormat;
+
+ public int CallCount { get; private set; }
+
+ public string? ProjectPath { get; private set; }
+
+ public IReadOnlyList SourcePaths { get; private set; } = Array.AsReadOnly(Array.Empty());
+
+ public async Task FormatAsync(
+ string projectPath,
+ IReadOnlyList sourcePaths,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
+ ArgumentNullException.ThrowIfNull(sourcePaths);
+ cancellationToken.ThrowIfCancellationRequested();
+ CallCount++;
+ ProjectPath = projectPath;
+ SourcePaths = Array.AsReadOnly([.. sourcePaths]);
+ if (_onFormat is not null)
+ {
+ await _onFormat(_fileSystem, projectPath, sourcePaths, cancellationToken);
+ }
+ }
+}
diff --git a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs
index a005ea75..02919293 100644
--- a/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs
+++ b/tests/OpenCode.Sdk.Tools.Tests/ToolAppTests.cs
@@ -2,8 +2,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenCode.Sdk.Tools.Commands;
+using OpenCode.Sdk.Tools.Generator;
using OpenCode.Sdk.Tools.Infrastructure;
using OpenCode.Sdk.Tools.Infrastructure.Logging;
+using OpenCode.Sdk.Tools.Output.Abstractions;
+using OpenCode.Sdk.Tools.Tests.Support;
using Spectre.Console;
using Spectre.Console.Cli;
using Spectre.Console.Cli.Testing;
@@ -24,12 +27,18 @@ public async Task CreateServices_Should_Resolve_Full_Hosting_Composition()
var console = provider.GetRequiredService();
var logger = provider.GetRequiredService>();
var loggingOptions = provider.GetRequiredService();
+ var coordinator = provider.GetRequiredService();
+ var writer = provider.GetRequiredService();
+ var formatter = provider.GetRequiredService();
var interceptors = provider.GetServices().ToArray();
await Assert.That(fileSystem).IsTypeOf();
await Assert.That(console).IsSameReferenceAs(AnsiConsole.Console);
await Assert.That(logger).IsNotNull();
await Assert.That(loggingOptions).IsNotNull();
+ await Assert.That(coordinator).IsNotNull();
+ await Assert.That(writer).IsNotNull();
+ await Assert.That(formatter).IsNotNull();
await Assert.That(interceptors.Length).IsEqualTo(1);
await Assert.That(interceptors[0]).IsTypeOf();
}
@@ -50,18 +59,19 @@ public async Task CreateRegistrar_Should_Apply_Seam_Overrides_After_Production_R
public async Task RunAsync_Should_Apply_Global_Logging_Options_Before_Command_Execution()
{
const string logPath = "logs/tool.log";
- var fileSystem = new MockFileSystem();
+ var fileSystem = GenerationTestData.CreateCommandFileSystem();
using var registrar = ToolApp.CreateRegistrar(services =>
{
services.AddSingleton(fileSystem);
services.AddSingleton();
+ services.AddSingleton(new RecordingProjectFormatter(fileSystem));
});
var tester = new CommandAppTester(registrar);
tester.Configure(ToolApp.Configure);
var result = await tester.RunAsync(["generate", "--log-level", "debug", "--log-file", logPath]);
- await Assert.That(result.ExitCode).IsEqualTo(1);
+ await Assert.That(result.ExitCode).IsEqualTo(0);
await Assert.That(fileSystem.File.Exists(logPath)).IsTrue();
var log = await fileSystem.File.ReadAllTextAsync(logPath, CancellationToken.None);
await Assert.That(log).Contains("Generate command invoked.");
@@ -80,15 +90,69 @@ public async Task RunAsync_Should_Return_Nonzero_When_Command_Is_Unknown()
}
[Test]
- public async Task RunAsync_Should_Fail_Loud_When_Generate_Is_Invoked()
+ public async Task RunAsync_Should_Generate_Selected_Output()
{
- using var registrar = ToolApp.CreateRegistrar();
+ var fileSystem = GenerationTestData.CreateCommandFileSystem();
+ using var registrar = ToolApp.CreateRegistrar(services =>
+ {
+ services.AddSingleton(fileSystem);
+ services.AddSingleton();
+ services.AddSingleton(new RecordingProjectFormatter(fileSystem));
+ });
var tester = new CommandAppTester(registrar);
tester.Configure(ToolApp.Configure);
var result = await tester.RunAsync(["generate"]);
+ await Assert.That(result.ExitCode).IsEqualTo(0);
+ await Assert.That(fileSystem.File.Exists(GenerationTestData.ManifestPath)).IsTrue();
+ await Assert.That(fileSystem.File.Exists(GenerationTestData.MarkerPath)).IsTrue();
+ await Assert.That(result.Output).Contains("Pending modern");
+ await Assert.That(result.Output).Contains("Pending legacy");
+ }
+
+ [Test]
+ public async Task RunAsync_Should_Return_Zero_When_Verify_Is_Clean()
+ {
+ var fileSystem = GenerationTestData.CreateCommandFileSystem();
+ using var registrar = ToolApp.CreateRegistrar(services =>
+ {
+ services.AddSingleton(fileSystem);
+ services.AddSingleton();
+ services.AddSingleton(new RecordingProjectFormatter(fileSystem));
+ });
+ var generateTester = new CommandAppTester(registrar);
+ generateTester.Configure(ToolApp.Configure);
+ _ = await generateTester.RunAsync(["generate"]);
+
+ var verifyTester = new CommandAppTester(registrar);
+ verifyTester.Configure(ToolApp.Configure);
+ var result = await verifyTester.RunAsync(["generate", "--verify"]);
+
+ await Assert.That(result.ExitCode).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task RunAsync_Should_Return_Nonzero_And_Report_Verify_Drift()
+ {
+ const string generatedPath = "src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs";
+ var fileSystem = GenerationTestData.CreateCommandFileSystem();
+ using var registrar = ToolApp.CreateRegistrar(services =>
+ {
+ services.AddSingleton(fileSystem);
+ services.AddSingleton();
+ services.AddSingleton(new RecordingProjectFormatter(fileSystem));
+ });
+ var generateTester = new CommandAppTester(registrar);
+ generateTester.Configure(ToolApp.Configure);
+ _ = await generateTester.RunAsync(["generate"]);
+ await fileSystem.File.WriteAllTextAsync(generatedPath, "drift", CancellationToken.None);
+
+ var verifyTester = new CommandAppTester(registrar);
+ verifyTester.Configure(ToolApp.Configure);
+ var result = await verifyTester.RunAsync(["generate", "--verify"]);
+
await Assert.That(result.ExitCode).IsEqualTo(1);
- await Assert.That(result.Output).Contains("not implemented");
+ await Assert.That(result.Output).Contains("Internal/Serialization/OpenCodeJsonContext.cs");
}
}
diff --git a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs
index 10365295..11a65534 100644
--- a/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs
+++ b/tools/OpenCode.Sdk.Tools/Commands/GenerateCommand.cs
@@ -1,42 +1,94 @@
+using System.ComponentModel;
+using System.Globalization;
using Microsoft.Extensions.Logging;
+using OpenCode.Sdk.Tools.Generator;
+using OpenCode.Sdk.Tools.Output;
using Spectre.Console;
using Spectre.Console.Cli;
namespace OpenCode.Sdk.Tools.Commands;
-/// Fail-loud stub; the generator pipeline replaces the body in a later slice.
-public sealed partial class GenerateCommand : AsyncCommand
+internal sealed partial class GenerateCommand : AsyncCommand
{
private readonly IAnsiConsole _console;
+ private readonly GenerationCoordinator _coordinator;
private readonly ILogger _logger;
- /// Creates the command with its console and logging seams.
- public GenerateCommand(IAnsiConsole console, ILogger logger)
+ public GenerateCommand(IAnsiConsole console, GenerationCoordinator coordinator, ILogger logger)
{
ArgumentNullException.ThrowIfNull(console);
+ ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(logger);
_console = console;
+ _coordinator = coordinator;
_logger = logger;
}
- ///
- protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
+ protected override async Task ExecuteAsync(
+ CommandContext context,
+ Settings settings,
+ CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(settings);
-
- cancellationToken.ThrowIfCancellationRequested();
-
LogInvocation(_logger);
- _console.MarkupLine("[red]generate is not implemented yet[/] — the generator pipeline has not landed.");
+ var report = await _coordinator.GenerateAsync(
+ GenerationRequest.RepositoryDefault(settings.Verify),
+ cancellationToken).ConfigureAwait(false);
+ WriteSummary(report);
- return Task.FromResult(1);
+ if (settings.Verify && report.WriteResult.HasChanges)
+ {
+ _console.MarkupLine("[red]Generated output was stale and has been repaired:[/]");
+ WritePaths(report.WriteResult);
+ return 1;
+ }
+
+ _console.MarkupLine(settings.Verify
+ ? "[green]Generated output is current.[/]"
+ : "[green]Generated output updated.[/]");
+ return 0;
}
[LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Generate command invoked.")]
private static partial void LogInvocation(ILogger logger);
- /// Defines settings for the generate command.
- public sealed class Settings : GlobalSettings;
+ private void WriteSummary(GenerationReport report)
+ {
+ _console.MarkupLine(string.Concat(
+ "[grey]Selected operations:[/] ",
+ report.SelectedOperationIds.Count.ToString(CultureInfo.InvariantCulture)));
+ _console.MarkupLine(string.Concat(
+ "[yellow]Pending modern operations:[/] ",
+ report.PendingModernOperationIds.Count.ToString(CultureInfo.InvariantCulture)));
+ _console.MarkupLine(string.Concat(
+ "[yellow]Pending legacy operations:[/] ",
+ report.PendingLegacyOperationIds.Count.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ private void WritePaths(WriteResult result)
+ {
+ foreach (var path in result.CreatedPaths)
+ {
+ _console.MarkupLine(string.Concat("[green]+[/] ", Markup.Escape(path)));
+ }
+
+ foreach (var path in result.ChangedPaths)
+ {
+ _console.MarkupLine(string.Concat("[yellow]~[/] ", Markup.Escape(path)));
+ }
+
+ foreach (var path in result.DeletedPaths)
+ {
+ _console.MarkupLine(string.Concat("[red]-[/] ", Markup.Escape(path)));
+ }
+ }
+
+ internal sealed class Settings : GlobalSettings
+ {
+ [CommandOption("--verify")]
+ [Description("Return a nonzero exit code when generated output was stale before this run.")]
+ public bool Verify { get; init; }
+ }
}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Abstractions/ISpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Abstractions/ISpecBinder.cs
new file mode 100644
index 00000000..280010b2
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Abstractions/ISpecBinder.cs
@@ -0,0 +1,9 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Abstractions;
+
+internal interface ISpecBinder
+{
+ public EmitPlan Bind(SpecDocument document, OperationSelection selection, GenerationCuration curation);
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs
new file mode 100644
index 00000000..28cfd31c
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/BindingErrorCollector.cs
@@ -0,0 +1,25 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class BindingErrorCollector
+{
+ private readonly List _errors = [];
+
+ public int Count => _errors.Count;
+
+ public void Add(BindingErrorCategory category, string subject, string problem)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(subject);
+ ArgumentException.ThrowIfNullOrWhiteSpace(problem);
+ _errors.Add(new BindingError(category, subject, problem));
+ }
+
+ public void ThrowIfAny()
+ {
+ if (_errors.Count > 0)
+ {
+ throw new BindingException(_errors);
+ }
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs
new file mode 100644
index 00000000..76dfd77b
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CSharpNamePolicy.cs
@@ -0,0 +1,111 @@
+using System.Text;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal static class CSharpNamePolicy
+{
+ public static string ToPascalCase(string wireName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(wireName);
+
+ var words = SplitWords(wireName);
+ var result = new StringBuilder(wireName.Length);
+ foreach (var word in words)
+ {
+ AppendPascalWord(result, word);
+ }
+
+ if (result.Length is 0)
+ {
+ throw new ArgumentException("Wire name must contain at least one letter or digit.", nameof(wireName));
+ }
+
+ if (char.IsDigit(result[0]))
+ {
+ result.Insert(0, '_');
+ }
+
+ return result.ToString();
+ }
+
+ public static string ToCamelCase(string wireName)
+ {
+ var pascal = ToPascalCase(wireName);
+ return string.Concat(char.ToLowerInvariant(pascal[0]), pascal[1..]);
+ }
+
+ public static bool IsValidIdentifier(string candidate)
+ {
+ if (string.IsNullOrEmpty(candidate) || !(char.IsLetter(candidate[0]) || candidate[0] is '_'))
+ {
+ return false;
+ }
+
+ return candidate.Skip(1).All(static character => char.IsLetterOrDigit(character) || character is '_');
+ }
+
+ public static IReadOnlyList SplitWords(string value)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(value);
+
+ var words = new List();
+ var start = -1;
+ for (var index = 0; index < value.Length; index++)
+ {
+ var current = value[index];
+ if (!char.IsLetterOrDigit(current))
+ {
+ AddWord(value, start, index, words);
+ start = -1;
+ continue;
+ }
+
+ if (start < 0)
+ {
+ start = index;
+ continue;
+ }
+
+ var previous = value[index - 1];
+ var nextIsLower = index + 1 < value.Length && char.IsLower(value[index + 1]);
+ var upperBoundary = char.IsUpper(current)
+ && (char.IsLower(previous) || char.IsDigit(previous) || (char.IsUpper(previous) && nextIsLower));
+ var digitBoundary = char.IsDigit(current) != char.IsDigit(previous)
+ && (char.IsDigit(current) || char.IsDigit(previous));
+ var boundary = upperBoundary || digitBoundary;
+ if (!boundary)
+ {
+ continue;
+ }
+
+ AddWord(value, start, index, words);
+ start = index;
+ }
+
+ AddWord(value, start, value.Length, words);
+ return Array.AsReadOnly([.. words]);
+ }
+
+ private static void AppendPascalWord(StringBuilder result, string word)
+ {
+ if (word.Length is 2 && word.All(char.IsLetter))
+ {
+ _ = result.Append(word.ToUpperInvariant());
+ return;
+ }
+
+ _ = result.Append(char.ToUpperInvariant(word[0]));
+ if (word.Length > 1)
+ {
+ _ = result.Append(word[1..].ToLowerInvariant());
+ }
+ }
+
+ private static void AddWord(string value, int start, int end, List words)
+ {
+ if (start >= 0 && end > start)
+ {
+ words.Add(value[start..end]);
+ }
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs
new file mode 100644
index 00000000..c248f9c2
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationLoader.cs
@@ -0,0 +1,42 @@
+using System.IO.Abstractions;
+using System.Text.Json;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Serialization;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class CurationLoader(IFileSystem fileSystem)
+{
+ private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+
+ public async Task LoadAsync(string curationPath, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(curationPath);
+
+ if (!_fileSystem.File.Exists(curationPath))
+ {
+ throw CreateException(curationPath, "curation file was not found");
+ }
+
+ var stream = _fileSystem.File.OpenRead(curationPath);
+ await using (stream.ConfigureAwait(false))
+ {
+ try
+ {
+ return await JsonSerializer.DeserializeAsync(
+ stream,
+ ToolJsonContext.Default.GenerationCuration,
+ cancellationToken)
+ .ConfigureAwait(false)
+ ?? throw CreateException(curationPath, "curation document cannot be JSON null");
+ }
+ catch (JsonException exception)
+ {
+ throw CreateException(curationPath, exception.Message);
+ }
+ }
+ }
+
+ private static BindingException CreateException(string subject, string problem) =>
+ new(Array.AsReadOnly([new BindingError(BindingErrorCategory.Curation, subject, problem)]));
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs
new file mode 100644
index 00000000..3ae1092c
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/CurationValidator.cs
@@ -0,0 +1,156 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class CurationValidator
+{
+ private readonly StringComparer _comparer = StringComparer.Ordinal;
+
+ public void Validate(SpecDocument document, IReadOnlyList selected, ReachableSchemaSet reachable,
+ GenerationCuration curation, BindingErrorCollector errors)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ ArgumentNullException.ThrowIfNull(selected);
+ ArgumentNullException.ThrowIfNull(reachable);
+ ArgumentNullException.ThrowIfNull(curation);
+ ArgumentNullException.ThrowIfNull(errors);
+
+ var selectedIds = selected.Select(static operation => operation.OperationId).ToHashSet(_comparer);
+ var selectedGroups = selected.Select(GetGroup).ToHashSet(_comparer);
+ var documentIds = document.Operations.Select(static operation => operation.OperationId).ToHashSet(_comparer);
+ var documentGroups = document.Operations.Select(GetGroup).ToHashSet(_comparer);
+ ValidateGroups(selected, selectedGroups, documentGroups, curation, errors);
+ ValidateEnvelopeNames(selected, selectedIds, documentIds, curation, errors);
+ ValidatePropertyOverrides(document, reachable, curation, errors);
+ }
+
+ private static void ValidateGroups(IReadOnlyList selected, HashSet selectedGroups,
+ HashSet documentGroups, GenerationCuration curation, BindingErrorCollector errors)
+ {
+ foreach (var group in selectedGroups.Where(group => !curation.Groups.ContainsKey(group)).Order(StringComparer.Ordinal))
+ {
+ errors.Add(BindingErrorCategory.Curation, group, "selected operation group has no curation row");
+ }
+
+ foreach (var (wireName, group) in curation.Groups.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
+ {
+ if (!documentGroups.Contains(wireName))
+ {
+ errors.Add(BindingErrorCategory.Curation, wireName, "curated group does not exist in the spec");
+ }
+ else if (!selectedGroups.Contains(wireName))
+ {
+ errors.Add(BindingErrorCategory.Curation, wireName, "curated group is not selected by the current profile");
+ }
+
+ ValidateGroupShape(wireName, group, selected, errors);
+ }
+ }
+
+ private static void ValidateGroupShape(string wireName, GroupCuration group, IReadOnlyList selected,
+ BindingErrorCollector errors)
+ {
+ switch (group.Placement)
+ {
+ case GroupPlacement.Root when group.ClientName is not null || group.HandleName is not null:
+ errors.Add(BindingErrorCategory.Curation, wireName, "root group cannot declare clientName or handleName");
+ break;
+ case GroupPlacement.Client when string.IsNullOrWhiteSpace(group.ClientName):
+ errors.Add(BindingErrorCategory.Curation, wireName, "client group must declare clientName");
+ break;
+ }
+
+ if (group.ClientName is not null && !CSharpNamePolicy.IsValidIdentifier(group.ClientName))
+ {
+ errors.Add(BindingErrorCategory.Naming, wireName, $"client name '{group.ClientName}' is not a valid C# identifier");
+ }
+
+ if (group.HandleName is not null && !CSharpNamePolicy.IsValidIdentifier(group.HandleName))
+ {
+ errors.Add(BindingErrorCategory.Naming, wireName, $"handle name '{group.HandleName}' is not a valid C# identifier");
+ }
+
+ var requiresSessionHandle = selected.Any(operation => string.Equals(GetGroup(operation), wireName, StringComparison.Ordinal)
+ && operation.Parameters.Any(static parameter => parameter is
+ {
+ Name: "sessionID",
+ Location: SpecParameterLocation.Path,
+ }));
+ if (requiresSessionHandle && string.IsNullOrWhiteSpace(group.HandleName))
+ {
+ errors.Add(BindingErrorCategory.Curation, wireName, "session-scoped selected operation requires handleName");
+ }
+ }
+
+ private static void ValidateEnvelopeNames(IReadOnlyList selected, HashSet selectedIds,
+ HashSet documentIds, GenerationCuration curation, BindingErrorCollector errors)
+ {
+ foreach (var operation in selected)
+ {
+ var requiresPayloadName = operation.Responses.Any(static response => response.StatusCode is >= 200 and < 300
+ && response.EnvelopeShape is SpecEnvelopeShape.Data or SpecEnvelopeShape.DataLocation
+ or SpecEnvelopeShape.CursorData or SpecEnvelopeShape.DataHasMore);
+ if (requiresPayloadName && !curation.EnvelopePayloadNames.ContainsKey(operation.OperationId))
+ {
+ errors.Add(BindingErrorCategory.Curation, operation.OperationId, "selected response envelope has no payload name");
+ }
+ }
+
+ foreach (var (operationId, payloadName) in curation.EnvelopePayloadNames.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
+ {
+ if (!documentIds.Contains(operationId))
+ {
+ errors.Add(BindingErrorCategory.Curation, operationId, "curated operation does not exist in the spec");
+ }
+ else if (!selectedIds.Contains(operationId))
+ {
+ errors.Add(BindingErrorCategory.Curation, operationId, "curated operation is not selected by the current profile");
+ }
+
+ if (!CSharpNamePolicy.IsValidIdentifier(payloadName))
+ {
+ errors.Add(BindingErrorCategory.Naming, operationId, $"payload name '{payloadName}' is not a valid C# identifier");
+ }
+ }
+ }
+
+ private static void ValidatePropertyOverrides(SpecDocument document, ReachableSchemaSet reachable,
+ GenerationCuration curation, BindingErrorCollector errors)
+ {
+ var reachableKeys = reachable.GraphKeys.ToHashSet(StringComparer.Ordinal);
+ var targets = new HashSet<(string Schema, string Property)>();
+ foreach (var propertyOverride in curation.PropertyOverrides)
+ {
+ var subject = $"{propertyOverride.Schema}.{propertyOverride.Property}";
+ if (!targets.Add((propertyOverride.Schema, propertyOverride.Property)))
+ {
+ errors.Add(BindingErrorCategory.Curation, subject, "property override is duplicated");
+ }
+
+ if (string.IsNullOrWhiteSpace(propertyOverride.Reason))
+ {
+ errors.Add(BindingErrorCategory.Curation, subject, "property override must declare a reason");
+ }
+
+ if (!document.Schemas.TryGetValue(propertyOverride.Schema, out var schema))
+ {
+ errors.Add(BindingErrorCategory.Curation, subject, "curated schema does not exist in the spec");
+ continue;
+ }
+
+ if (!reachableKeys.Contains(propertyOverride.Schema))
+ {
+ errors.Add(BindingErrorCategory.Curation, subject, "curated property is not selected by the current profile");
+ }
+
+ if (schema is not ObjectNode objectSchema
+ || !objectSchema.Properties.Any(property => string.Equals(property.Name, propertyOverride.Property, StringComparison.Ordinal)))
+ {
+ errors.Add(BindingErrorCategory.Curation, subject, "curated property does not exist on the schema");
+ }
+ }
+ }
+
+ private static string GetGroup(SpecOperation operation) => operation.Segments[0];
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingError.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingError.cs
new file mode 100644
index 00000000..60b68832
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingError.cs
@@ -0,0 +1,7 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+/// Describes one located failure discovered while binding selected operations.
+/// The binding stage that refused the input.
+/// The operation, schema, or curation key associated with the failure.
+/// A description of the failure.
+public sealed record BindingError(BindingErrorCategory Category, string Subject, string Problem);
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs
new file mode 100644
index 00000000..d231e729
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingErrorCategory.cs
@@ -0,0 +1,17 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+/// Identifies the generator stage that refused a binding input.
+public enum BindingErrorCategory
+{
+ /// The checked-in operation selection is invalid.
+ Selection = 0,
+
+ /// The public API curation is invalid or incomplete.
+ Curation = 1,
+
+ /// A selected schema cannot be represented by the emitter plan.
+ Schema = 2,
+
+ /// A selected wire name cannot be mapped to a unique C# identifier.
+ Naming = 3,
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingException.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingException.cs
new file mode 100644
index 00000000..2de74c42
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/BindingException.cs
@@ -0,0 +1,76 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Text;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+/// Represents one or more failures that prevent generator binding.
+public sealed class BindingException : Exception
+{
+ private static readonly IReadOnlyList NoErrors = Array.AsReadOnly(Array.Empty());
+
+ /// Initializes an exception without binding errors.
+ public BindingException()
+ {
+ Errors = NoErrors;
+ }
+
+ /// Initializes an exception with a caller-supplied message.
+ /// The message that describes the exception.
+ public BindingException(string message)
+ : base(message ?? throw new ArgumentNullException(nameof(message)))
+ {
+ Errors = NoErrors;
+ }
+
+ /// Initializes an exception with a caller-supplied message and inner exception.
+ /// The message that describes the exception.
+ /// The exception that caused this exception.
+ public BindingException(string message, Exception innerException)
+ : base(message ?? throw new ArgumentNullException(nameof(message)), innerException ?? throw new ArgumentNullException(nameof(innerException)))
+ {
+ Errors = NoErrors;
+ }
+
+ /// Initializes an exception for the supplied binding errors.
+ /// The failures that prevent binding.
+ public BindingException(IReadOnlyList errors)
+ : this(CopyErrors(errors))
+ {
+ }
+
+ private BindingException(ReadOnlyCollection errors)
+ : base(CreateMessage(errors))
+ {
+ Errors = errors;
+ }
+
+ /// Gets the failures that prevent binding.
+ public IReadOnlyList Errors { get; }
+
+ private static ReadOnlyCollection CopyErrors(IReadOnlyList errors)
+ {
+ ArgumentNullException.ThrowIfNull(errors);
+ return Array.AsReadOnly([.. errors]);
+ }
+
+ private static string CreateMessage(ReadOnlyCollection errors)
+ {
+ var message = new StringBuilder("Binding failed with ")
+ .Append(errors.Count.ToString(CultureInfo.InvariantCulture))
+ .Append(" error(s):");
+ foreach (var error in errors)
+ {
+ message
+ .Append('\n')
+ .Append("- ")
+ .Append(error.Category)
+ .Append(" [")
+ .Append(error.Subject)
+ .Append("]: ")
+ .Append(error.Problem);
+ }
+
+ return message.ToString();
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/DictionaryTypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/DictionaryTypeReferencePlan.cs
new file mode 100644
index 00000000..b0c3d3b0
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/DictionaryTypeReferencePlan.cs
@@ -0,0 +1,8 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record DictionaryTypeReferencePlan : TypeReferencePlan
+{
+ public required TypeReferencePlan ValueType { get; init; }
+
+ public override bool IsCollection => true;
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EmitPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EmitPlan.cs
new file mode 100644
index 00000000..aec3e283
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EmitPlan.cs
@@ -0,0 +1,46 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record EmitPlan
+{
+ public required IReadOnlyList SelectedOperationIds
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required IReadOnlyList Models
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required IReadOnlyList Unions
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required RegistryPlan Registry { get; init; }
+
+ public required IReadOnlyList PendingOperations
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumModelPlan.cs
new file mode 100644
index 00000000..1b79a362
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumModelPlan.cs
@@ -0,0 +1,14 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record EnumModelPlan : ModelPlan
+{
+ public required IReadOnlyList Values
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumValuePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumValuePlan.cs
new file mode 100644
index 00000000..24533acf
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/EnumValuePlan.cs
@@ -0,0 +1,8 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record EnumValuePlan
+{
+ public required string Name { get; init; }
+
+ public required string WireValue { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs
new file mode 100644
index 00000000..d40cb38c
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GenerationCuration.cs
@@ -0,0 +1,40 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record GenerationCuration
+{
+ [JsonPropertyName("groups")]
+ public required IReadOnlyDictionary Groups
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(new Dictionary(value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ [JsonPropertyName("envelopePayloadNames")]
+ public required IReadOnlyDictionary EnvelopePayloadNames
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = new ReadOnlyDictionary(new Dictionary(value, StringComparer.Ordinal));
+ }
+ } = new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal));
+
+ [JsonPropertyName("propertyOverrides")]
+ public required IReadOnlyList PropertyOverrides
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupCuration.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupCuration.cs
new file mode 100644
index 00000000..0270c47b
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupCuration.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record GroupCuration
+{
+ [JsonPropertyName("placement")]
+ public required GroupPlacement Placement { get; init; }
+
+ [JsonPropertyName("clientName")]
+ public string? ClientName { get; init; }
+
+ [JsonPropertyName("handleName")]
+ public string? HandleName { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupPlacement.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupPlacement.cs
new file mode 100644
index 00000000..7a6d97bc
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/GroupPlacement.cs
@@ -0,0 +1,7 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal enum GroupPlacement
+{
+ Root = 0,
+ Client = 1,
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ListTypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ListTypeReferencePlan.cs
new file mode 100644
index 00000000..7319ccb7
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ListTypeReferencePlan.cs
@@ -0,0 +1,8 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record ListTypeReferencePlan : TypeReferencePlan
+{
+ public required TypeReferencePlan ElementType { get; init; }
+
+ public override bool IsCollection => true;
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPlan.cs
new file mode 100644
index 00000000..f4b7ed51
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPlan.cs
@@ -0,0 +1,10 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal abstract record ModelPlan
+{
+ public required string Name { get; init; }
+
+ public required string Namespace { get; init; }
+
+ public string? Description { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs
new file mode 100644
index 00000000..38fd6bbc
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ModelPropertyPlan.cs
@@ -0,0 +1,22 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record ModelPropertyPlan
+{
+ public required string WireName { get; init; }
+
+ public required string Name { get; init; }
+
+ public required TypeReferencePlan Type { get; init; }
+
+ public required bool IsRequired { get; init; }
+
+ public required bool IsLiteral { get; init; }
+
+ public LiteralKind? LiteralKind { get; init; }
+
+ public string? LiteralValue { get; init; }
+
+ public string? Description { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/NamedTypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/NamedTypeReferencePlan.cs
new file mode 100644
index 00000000..787f19e3
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/NamedTypeReferencePlan.cs
@@ -0,0 +1,8 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record NamedTypeReferencePlan : TypeReferencePlan
+{
+ public required string Name { get; init; }
+
+ public override bool IsCollection => false;
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs
new file mode 100644
index 00000000..7cd90e7d
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ObjectModelPlan.cs
@@ -0,0 +1,16 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record ObjectModelPlan : ModelPlan
+{
+ public required IReadOnlyList Properties
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public string? BaseTypeName { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationSelection.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationSelection.cs
new file mode 100644
index 00000000..43b47673
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/OperationSelection.cs
@@ -0,0 +1,14 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record OperationSelection
+{
+ public required IReadOnlyList OperationIds
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PendingOperationPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PendingOperationPlan.cs
new file mode 100644
index 00000000..762a30aa
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PendingOperationPlan.cs
@@ -0,0 +1,10 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record PendingOperationPlan
+{
+ public required string OperationId { get; init; }
+
+ public required SpecSurface Surface { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverride.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverride.cs
new file mode 100644
index 00000000..0dbffe88
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverride.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record PropertyOverride
+{
+ [JsonPropertyName("schema")]
+ public required string Schema { get; init; }
+
+ [JsonPropertyName("property")]
+ public required string Property { get; init; }
+
+ [JsonPropertyName("type")]
+ public required PropertyOverrideType Type { get; init; }
+
+ [JsonPropertyName("reason")]
+ public required string Reason { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverrideType.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverrideType.cs
new file mode 100644
index 00000000..1c155a23
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PropertyOverrideType.cs
@@ -0,0 +1,6 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal enum PropertyOverrideType
+{
+ Uri = 0,
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ReachableSchemaSet.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ReachableSchemaSet.cs
new file mode 100644
index 00000000..b4e3180a
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/ReachableSchemaSet.cs
@@ -0,0 +1,24 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record ReachableSchemaSet
+{
+ public required IReadOnlyList GraphKeys
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required IReadOnlyList ResponseRootKeys
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RegistryPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RegistryPlan.cs
new file mode 100644
index 00000000..23329e8d
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/RegistryPlan.cs
@@ -0,0 +1,14 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record RegistryPlan
+{
+ public required IReadOnlyList TypeNames
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs
new file mode 100644
index 00000000..7a8f2a45
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/SchemaBindingResult.cs
@@ -0,0 +1,10 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record SchemaBindingResult
+{
+ public required IReadOnlyList Models { get; init; }
+
+ public required IReadOnlyList Unions { get; init; }
+
+ public required RegistryPlan Registry { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/TypeReferencePlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/TypeReferencePlan.cs
new file mode 100644
index 00000000..bd09940e
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/TypeReferencePlan.cs
@@ -0,0 +1,8 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal abstract record TypeReferencePlan
+{
+ public required bool IsNullable { get; init; }
+
+ public abstract bool IsCollection { get; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionPlan.cs
new file mode 100644
index 00000000..97ff2ac8
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionPlan.cs
@@ -0,0 +1,30 @@
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record UnionPlan
+{
+ public required string Name { get; init; }
+
+ public required string Namespace { get; init; }
+
+ public required string UnknownTypeName { get; init; }
+
+ public required string MarkerWireName { get; init; }
+
+ public required string MarkerName { get; init; }
+
+ public required LiteralKind MarkerKind { get; init; }
+
+ public required IReadOnlyList Variants
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public string? Description { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionVariantPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionVariantPlan.cs
new file mode 100644
index 00000000..e2ae45e5
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/UnionVariantPlan.cs
@@ -0,0 +1,8 @@
+namespace OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+internal sealed record UnionVariantPlan
+{
+ public required string TypeName { get; init; }
+
+ public required string Tag { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationSelectionLoader.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationSelectionLoader.cs
new file mode 100644
index 00000000..2d941dbe
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/OperationSelectionLoader.cs
@@ -0,0 +1,60 @@
+using System.IO.Abstractions;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class OperationSelectionLoader(IFileSystem fileSystem)
+{
+ private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+
+ public async Task LoadAsync(string profilePath, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(profilePath);
+
+ var errors = new BindingErrorCollector();
+ if (!_fileSystem.File.Exists(profilePath))
+ {
+ errors.Add(BindingErrorCategory.Selection, profilePath, "generation profile was not found");
+ errors.ThrowIfAny();
+ }
+
+ var lines = await _fileSystem.File.ReadAllLinesAsync(profilePath, cancellationToken).ConfigureAwait(false);
+ var operationIds = new List(lines.Length);
+ var seen = new HashSet(StringComparer.Ordinal);
+ for (var index = 0; index < lines.Length; index++)
+ {
+ var line = lines[index];
+ var subject = $"{profilePath}:{(index + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)}";
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ errors.Add(BindingErrorCategory.Selection, subject, "generation profile cannot contain a blank line");
+ continue;
+ }
+
+ if (!string.Equals(line, line.Trim(), StringComparison.Ordinal))
+ {
+ errors.Add(BindingErrorCategory.Selection, subject, "operation ID cannot carry leading or trailing whitespace");
+ continue;
+ }
+
+ if (!seen.Add(line))
+ {
+ errors.Add(BindingErrorCategory.Selection, subject, $"duplicate operation ID '{line}'");
+ continue;
+ }
+
+ operationIds.Add(line);
+ }
+
+ if (operationIds.Count is 0)
+ {
+ errors.Add(BindingErrorCategory.Selection, profilePath, "generation profile must select at least one operation");
+ }
+
+ errors.ThrowIfAny();
+ return new OperationSelection
+ {
+ OperationIds = operationIds,
+ };
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs
new file mode 100644
index 00000000..21448a7c
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReachableSchemaCollector.cs
@@ -0,0 +1,99 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class ReachableSchemaCollector
+{
+ private readonly StringComparer _comparer = StringComparer.Ordinal;
+
+ public ReachableSchemaSet Collect(SpecDocument document, IReadOnlyList operations, BindingErrorCollector errors)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ ArgumentNullException.ThrowIfNull(operations);
+ ArgumentNullException.ThrowIfNull(errors);
+
+ var traversal = new ReachabilityTraversal(document.Schemas, errors, _comparer);
+ foreach (var operation in operations)
+ {
+ foreach (var parameter in operation.Parameters)
+ {
+ traversal.Visit(parameter.Schema);
+ }
+
+ if (operation.RequestBody is not null)
+ {
+ traversal.Visit(operation.RequestBody.Schema);
+ }
+
+ foreach (var response in operation.Responses)
+ {
+ traversal.VisitResponse(operation.OperationId, response.Schema);
+ }
+ }
+
+ return traversal.Snapshot();
+ }
+
+ private sealed class ReachabilityTraversal(IReadOnlyDictionary graph, BindingErrorCollector errors,
+ StringComparer comparer)
+ {
+ private readonly StringComparer _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
+ private readonly BindingErrorCollector _errors = errors ?? throw new ArgumentNullException(nameof(errors));
+ private readonly IReadOnlyDictionary _graph = graph ?? throw new ArgumentNullException(nameof(graph));
+ private readonly HashSet _keys = new(comparer);
+ private readonly HashSet _responseRoots = new(comparer);
+
+ public void VisitResponse(string operationId, SchemaNode? schema)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(operationId);
+ if (schema is RefNode reference && reference.Target.StartsWith($"op:{operationId}#", StringComparison.Ordinal))
+ {
+ _ = _responseRoots.Add(reference.Target);
+ }
+
+ if (schema is not null)
+ {
+ Visit(schema);
+ }
+ }
+
+ public void Visit(SchemaNode schema)
+ {
+ ArgumentNullException.ThrowIfNull(schema);
+ if (schema is RefNode reference)
+ {
+ VisitReference(reference.Target);
+ return;
+ }
+
+ foreach (var child in schema.Children)
+ {
+ Visit(child);
+ }
+ }
+
+ public ReachableSchemaSet Snapshot() =>
+ new()
+ {
+ GraphKeys = [.. _keys.Order(_comparer)],
+ ResponseRootKeys = [.. _responseRoots.Order(_comparer)],
+ };
+
+ private void VisitReference(string target)
+ {
+ if (!_keys.Add(target))
+ {
+ return;
+ }
+
+ if (!_graph.TryGetValue(target, out var targetSchema))
+ {
+ _errors.Add(BindingErrorCategory.Schema, target, "referenced schema is absent from the ingested graph");
+ return;
+ }
+
+ Visit(targetSchema);
+ }
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs
new file mode 100644
index 00000000..98f370fd
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaNameResolver.cs
@@ -0,0 +1,120 @@
+using System.Collections.ObjectModel;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class SchemaNameResolver
+{
+ private readonly StringComparer _comparer = StringComparer.Ordinal;
+
+ public IReadOnlyDictionary Resolve(SpecDocument document, ReachableSchemaSet reachable,
+ BindingErrorCollector errors)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ ArgumentNullException.ThrowIfNull(reachable);
+ ArgumentNullException.ThrowIfNull(errors);
+
+ var responseRoots = reachable.ResponseRootKeys.ToHashSet(_comparer);
+ var result = new Dictionary(_comparer);
+ var owners = new Dictionary(_comparer);
+ foreach (var key in reachable.GraphKeys)
+ {
+ if (responseRoots.Contains(key) || !document.Schemas.TryGetValue(key, out var schema) || !IsNominal(schema))
+ {
+ continue;
+ }
+
+ var 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}'");
+ continue;
+ }
+
+ owners.Add(name, key);
+ result.Add(key, name);
+ }
+
+ return new ReadOnlyDictionary(result);
+ }
+
+ private static bool IsNominal(SchemaNode schema) => schema is ObjectNode or EnumNode or UnionNode;
+
+ private string ResolveUnionName(string key, UnionNode union, IReadOnlyDictionary graph)
+ {
+ if (!key.Contains('#', StringComparison.Ordinal))
+ {
+ return ResolveDefault(key);
+ }
+
+ var branchNames = union.Branches
+ .OfType()
+ .Where(reference => graph.ContainsKey(reference.Target))
+ .Select(reference => ResolveDefault(reference.Target))
+ .ToArray();
+ if (branchNames.Length != union.Branches.Count)
+ {
+ return ResolveDefault(key);
+ }
+
+ var commonWords = CSharpNamePolicy.SplitWords(branchNames[0]).ToArray();
+ foreach (var branchName in branchNames.Skip(1))
+ {
+ var words = CSharpNamePolicy.SplitWords(branchName);
+ var commonLength = 0;
+ while (commonLength < commonWords.Length && commonLength < words.Count
+ && StringComparer.OrdinalIgnoreCase.Equals(commonWords[commonLength], words[commonLength]))
+ {
+ commonLength++;
+ }
+
+ commonWords = commonWords[..commonLength];
+ }
+
+ var owner = CSharpNamePolicy.ToPascalCase(GetRoot(key));
+ var common = string.Concat(commonWords.Select(CSharpNamePolicy.ToPascalCase));
+ return common.Length > 0 && !_comparer.Equals(common, owner)
+ ? common
+ : ResolveDefault(key);
+ }
+
+ private static string ResolveDefault(string key)
+ {
+ var root = GetRoot(key);
+ var result = new System.Text.StringBuilder(CSharpNamePolicy.ToPascalCase(NormalizeRoot(root)));
+ var hash = key.IndexOf('#', StringComparison.Ordinal);
+ if (hash < 0)
+ {
+ return result.ToString();
+ }
+
+ var segments = key[(hash + 1)..].Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var index = 0;
+ while (index < segments.Length - 1)
+ {
+ if (!string.Equals(segments[index], "properties", StringComparison.Ordinal))
+ {
+ index++;
+ continue;
+ }
+
+ _ = result.Append(CSharpNamePolicy.ToPascalCase(DecodePointer(segments[index + 1])));
+ index += 2;
+ }
+
+ return result.ToString();
+ }
+
+ private static string GetRoot(string key)
+ {
+ var hash = key.IndexOf('#', StringComparison.Ordinal);
+ return hash < 0 ? key : key[..hash];
+ }
+
+ private static string NormalizeRoot(string root) => root.StartsWith("op:", StringComparison.Ordinal) ? root[3..] : root;
+
+ private static string DecodePointer(string segment) => segment
+ .Replace("~1", "/", StringComparison.Ordinal)
+ .Replace("~0", "~", StringComparison.Ordinal);
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs
new file mode 100644
index 00000000..a7a20691
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SchemaPlanBinder.cs
@@ -0,0 +1,455 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class SchemaPlanBinder(SchemaNameResolver schemaNames)
+{
+ private const string ModelNamespace = "OpenCode.Sdk.Models";
+ private readonly SchemaNameResolver _schemaNames = schemaNames ?? throw new ArgumentNullException(nameof(schemaNames));
+
+ public SchemaBindingResult Bind(SpecDocument document, ReachableSchemaSet reachable, GenerationCuration curation,
+ BindingErrorCollector errors)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ ArgumentNullException.ThrowIfNull(reachable);
+ ArgumentNullException.ThrowIfNull(curation);
+ ArgumentNullException.ThrowIfNull(errors);
+
+ var typeNames = _schemaNames.Resolve(document, reachable, errors);
+ var responseRoots = reachable.ResponseRootKeys.ToHashSet(StringComparer.Ordinal);
+ RefuseStructuralUnions(document, reachable, errors);
+
+ var inheritance = new Dictionary(StringComparer.Ordinal);
+ var unions = BindExplicitUnions(document, reachable, responseRoots, typeNames, inheritance, errors);
+ var errorUnion = BindErrorUnion(document, reachable, responseRoots, typeNames, inheritance, errors);
+ if (errorUnion is not null)
+ {
+ unions.Add(errorUnion);
+ }
+
+ var typeBinder = new TypePlanBinder(document.Schemas, typeNames, curation.PropertyOverrides, errors);
+ var models = new List();
+ foreach (var key in reachable.GraphKeys)
+ {
+ if (responseRoots.Contains(key) || !document.Schemas.TryGetValue(key, out var schema) || !typeNames.TryGetValue(key, out var name))
+ {
+ continue;
+ }
+
+ switch (schema)
+ {
+ case ObjectNode objectNode:
+ var objectPlan = BindObject(key, name, objectNode, inheritance, typeBinder, errors);
+ if (objectPlan is not null)
+ {
+ models.Add(objectPlan);
+ }
+
+ break;
+ case EnumNode enumNode:
+ models.Add(BindEnum(name, enumNode, errors));
+ break;
+ }
+ }
+
+ var orderedModels = models.OrderBy(static model => model.Name, StringComparer.Ordinal).ToArray();
+ var orderedUnions = unions.OrderBy(static union => union.Name, StringComparer.Ordinal).ToArray();
+ var registryNames = orderedModels.Select(static model => model.Name)
+ .Concat(orderedUnions.SelectMany(static union => new[] { union.Name, union.UnknownTypeName, }))
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+ return new SchemaBindingResult
+ {
+ Models = orderedModels,
+ Unions = orderedUnions,
+ Registry = new RegistryPlan
+ {
+ TypeNames = registryNames,
+ },
+ };
+ }
+
+ private static void RefuseStructuralUnions(SpecDocument document, ReachableSchemaSet reachable, BindingErrorCollector errors)
+ {
+ foreach (var key in reachable.GraphKeys)
+ {
+ if (document.Schemas.TryGetValue(key, out var schema) && schema is UnionNode { Classification: UnionClassification.Structural })
+ {
+ errors.Add(BindingErrorCategory.Schema, key, "selected closure contains a structural union");
+ }
+ }
+ }
+
+ private static List BindExplicitUnions(SpecDocument document, ReachableSchemaSet reachable, HashSet responseRoots,
+ IReadOnlyDictionary names, IDictionary inheritance, BindingErrorCollector errors)
+ {
+ var result = new List();
+ foreach (var key in reachable.GraphKeys)
+ {
+ if (responseRoots.Contains(key)
+ || !document.Schemas.TryGetValue(key, out var schema)
+ || schema is not UnionNode { Classification: UnionClassification.Marked } union
+ || !names.TryGetValue(key, out var name))
+ {
+ continue;
+ }
+
+ var plan = BindUnion(name, key, union, document.Schemas, names, inheritance, errors);
+ if (plan is not null)
+ {
+ result.Add(plan);
+ }
+ }
+
+ return result;
+ }
+
+ private static UnionPlan? BindUnion(string name, string key, UnionNode union, IReadOnlyDictionary graph,
+ IReadOnlyDictionary names, IDictionary inheritance, BindingErrorCollector errors)
+ {
+ var variants = new List(union.Branches.Count);
+ LiteralMarker? expectedMarker = null;
+ var tags = new HashSet(StringComparer.Ordinal);
+ foreach (var branch in union.Branches)
+ {
+ if (branch is not RefNode reference
+ || !graph.TryGetValue(reference.Target, out var target)
+ || target is not ObjectNode { LiteralMarkers.Count: > 0 } objectNode
+ || !names.TryGetValue(reference.Target, out var typeName))
+ {
+ errors.Add(BindingErrorCategory.Schema, key, "marked union branch must reference a named object with a literal marker");
+ continue;
+ }
+
+ var marker = objectNode.LiteralMarkers[0];
+ expectedMarker ??= marker;
+ if (!string.Equals(marker.PropertyName, expectedMarker.PropertyName, StringComparison.Ordinal) || marker.Kind != expectedMarker.Kind)
+ {
+ errors.Add(BindingErrorCategory.Schema, key, "marked union branches do not share one marker property and kind");
+ continue;
+ }
+
+ if (!tags.Add(marker.Value))
+ {
+ errors.Add(BindingErrorCategory.Schema, key, $"marked union tag '{marker.Value}' is duplicated");
+ continue;
+ }
+
+ AddInheritance(reference.Target, name, inheritance, errors);
+ variants.Add(new UnionVariantPlan
+ {
+ TypeName = typeName,
+ Tag = marker.Value,
+ });
+ }
+
+ return expectedMarker is null || variants.Count != union.Branches.Count
+ ? null
+ : new UnionPlan
+ {
+ Name = name,
+ Namespace = ModelNamespace,
+ UnknownTypeName = $"Unknown{name}",
+ MarkerWireName = expectedMarker.PropertyName,
+ MarkerName = CSharpNamePolicy.ToPascalCase(expectedMarker.PropertyName),
+ MarkerKind = expectedMarker.Kind,
+ Variants = variants,
+ Description = union.Description,
+ };
+ }
+
+ private static UnionPlan? BindErrorUnion(SpecDocument document, ReachableSchemaSet reachable, HashSet responseRoots,
+ IReadOnlyDictionary names, IDictionary inheritance, BindingErrorCollector errors)
+ {
+ var errorsInClosure = new List>();
+ foreach (var key in reachable.GraphKeys)
+ {
+ if (!responseRoots.Contains(key)
+ && document.Schemas.TryGetValue(key, out var schema)
+ && schema is ObjectNode { ErrorStyle: not ErrorStyle.None } objectNode)
+ {
+ errorsInClosure.Add(new KeyValuePair(key, objectNode));
+ }
+ }
+
+ if (errorsInClosure.Count is 0)
+ {
+ return null;
+ }
+
+ var styles = errorsInClosure.Select(static pair => pair.Value.ErrorStyle).Distinct().ToArray();
+ if (styles is not [ErrorStyle.EffectTag])
+ {
+ errors.Add(BindingErrorCategory.Schema, "OpenCodeError", "selected errors must use the Effect _tag style in M1");
+ return null;
+ }
+
+ var variants = new List(errorsInClosure.Count);
+ foreach (var (key, objectNode) in errorsInClosure.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
+ {
+ if (!names.TryGetValue(key, out var typeName))
+ {
+ errors.Add(BindingErrorCategory.Naming, key, "error schema has no unique C# type name");
+ continue;
+ }
+
+ var markers = objectNode.LiteralMarkers.Where(static marker => marker.PropertyName is "_tag").ToArray();
+ if (markers is not [var marker])
+ {
+ errors.Add(BindingErrorCategory.Schema, key, "Effect error must declare exactly one required _tag literal");
+ continue;
+ }
+
+ AddInheritance(key, "OpenCodeError", inheritance, errors);
+ variants.Add(new UnionVariantPlan
+ {
+ TypeName = typeName,
+ Tag = marker.Value,
+ });
+ }
+
+ return new UnionPlan
+ {
+ Name = "OpenCodeError",
+ Namespace = ModelNamespace,
+ UnknownTypeName = "UnknownOpenCodeError",
+ MarkerWireName = "_tag",
+ MarkerName = "Tag",
+ MarkerKind = LiteralKind.String,
+ Variants = [.. variants.OrderBy(static variant => variant.TypeName, StringComparer.Ordinal)],
+ Description = "Represents a typed error returned by the opencode API.",
+ };
+ }
+
+ private static ObjectModelPlan? BindObject(string key, string name, ObjectNode node, IReadOnlyDictionary inheritance,
+ TypePlanBinder typeBinder, BindingErrorCollector errors)
+ {
+ var properties = new List(node.Properties.Count);
+ foreach (var property in node.Properties)
+ {
+ var type = typeBinder.Bind(key, property.Name, property.Schema);
+ if (type is null)
+ {
+ continue;
+ }
+
+ if (!property.IsRequired && !type.IsCollection)
+ {
+ type = type with { IsNullable = true };
+ }
+
+ var literal = property.Schema as LiteralNode;
+ properties.Add(new ModelPropertyPlan
+ {
+ WireName = property.Name,
+ Name = CSharpNamePolicy.ToPascalCase(property.Name),
+ Type = type,
+ IsRequired = property.IsRequired,
+ IsLiteral = literal is not null,
+ LiteralKind = literal?.Kind,
+ LiteralValue = literal?.Value,
+ Description = property.Schema.Description,
+ });
+ }
+
+ if (properties.Count != node.Properties.Count)
+ {
+ return null;
+ }
+
+ var duplicate = properties.GroupBy(static property => property.Name, StringComparer.Ordinal)
+ .FirstOrDefault(static group => group.Count() > 1);
+ if (duplicate is not null)
+ {
+ errors.Add(BindingErrorCategory.Naming, key, $"multiple properties map to C# name '{duplicate.Key}'");
+ return null;
+ }
+
+ return new ObjectModelPlan
+ {
+ Name = name,
+ Namespace = ModelNamespace,
+ Description = node.Description,
+ Properties = properties,
+ BaseTypeName = inheritance.GetValueOrDefault(key),
+ };
+ }
+
+ private static EnumModelPlan BindEnum(string name, EnumNode node, BindingErrorCollector errors)
+ {
+ var values = node.Values.Select(value => new EnumValuePlan
+ {
+ Name = CSharpNamePolicy.ToPascalCase(value),
+ WireValue = value,
+ }).ToArray();
+ var duplicate = values.GroupBy(static value => value.Name, StringComparer.Ordinal).FirstOrDefault(static group => group.Count() > 1);
+ if (duplicate is not null)
+ {
+ errors.Add(BindingErrorCategory.Naming, name, $"multiple enum values map to C# name '{duplicate.Key}'");
+ }
+
+ return new EnumModelPlan
+ {
+ Name = name,
+ Namespace = ModelNamespace,
+ Description = node.Description,
+ Values = values,
+ };
+ }
+
+ private static void AddInheritance(string schemaKey, string baseType, IDictionary inheritance,
+ BindingErrorCollector errors)
+ {
+ if (inheritance.TryGetValue(schemaKey, out var existing) && !string.Equals(existing, baseType, StringComparison.Ordinal))
+ {
+ errors.Add(BindingErrorCategory.Schema, schemaKey, $"schema cannot derive from both '{existing}' and '{baseType}'");
+ return;
+ }
+
+ inheritance[schemaKey] = baseType;
+ }
+
+ private sealed class TypePlanBinder
+ {
+ private readonly IReadOnlyDictionary _graph;
+ private readonly IReadOnlyDictionary _names;
+ private readonly Dictionary _overrides;
+ private readonly BindingErrorCollector _errors;
+
+ public TypePlanBinder(IReadOnlyDictionary graph, IReadOnlyDictionary names,
+ IReadOnlyList overrides, BindingErrorCollector errors)
+ {
+ _graph = graph ?? throw new ArgumentNullException(nameof(graph));
+ _names = names ?? throw new ArgumentNullException(nameof(names));
+ ArgumentNullException.ThrowIfNull(overrides);
+ _overrides = new Dictionary(StringComparer.Ordinal);
+ foreach (var propertyOverride in overrides)
+ {
+ _overrides.TryAdd($"{propertyOverride.Schema}\0{propertyOverride.Property}", propertyOverride.Type);
+ }
+
+ _errors = errors ?? throw new ArgumentNullException(nameof(errors));
+ }
+
+ public TypeReferencePlan? Bind(string schemaKey, string propertyName, SchemaNode schema)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(schemaKey);
+ ArgumentException.ThrowIfNullOrWhiteSpace(propertyName);
+ ArgumentNullException.ThrowIfNull(schema);
+
+ var type = BindCore(schema, $"{schemaKey}.{propertyName}", []);
+ if (type is null || !_overrides.TryGetValue($"{schemaKey}\0{propertyName}", out var propertyOverride))
+ {
+ return type;
+ }
+
+ if (propertyOverride is PropertyOverrideType.Uri && type is NamedTypeReferencePlan { Name: "string" })
+ {
+ return new NamedTypeReferencePlan
+ {
+ Name = "Uri",
+ IsNullable = type.IsNullable,
+ };
+ }
+
+ _errors.Add(BindingErrorCategory.Curation, $"{schemaKey}.{propertyName}", "uri override requires a scalar string property");
+ return null;
+ }
+
+ private TypeReferencePlan? BindCore(SchemaNode schema, string subject, HashSet aliases) => schema switch
+ {
+ RefNode reference => BindReference(reference, subject, aliases),
+ PrimitiveNode primitive => BindPrimitive(primitive),
+ LiteralNode literal => Named(literal.Kind is LiteralKind.Boolean ? "bool" : "string"),
+ ArrayNode array => BindArray(array, subject, aliases),
+ DictionaryNode dictionary => BindDictionary(dictionary, subject, aliases),
+ FreeFormObjectNode => DictionaryOf(Named("JsonElement")),
+ UnrestrictedNode => Named("JsonElement"),
+ SpecialNumberNode => Named("double"),
+ NullableNode nullable => BindNullable(nullable, subject, aliases),
+ JsonStringNode => Refuse(subject, "JSON-encoded strings are not supported by the M1 emitter"),
+ TupleNode => Refuse(subject, "tuple schemas are not supported by the M1 emitter"),
+ ObjectNode or EnumNode or UnionNode => Refuse(subject, "inline nominal schema was not promoted into the graph"),
+ _ => Refuse(subject, $"schema node '{schema.GetType().Name}' is not supported by the M1 emitter"),
+ };
+
+ private TypeReferencePlan? BindReference(RefNode reference, string subject, HashSet aliases)
+ {
+ if (_names.TryGetValue(reference.Target, out var name))
+ {
+ return Named(name);
+ }
+
+ if (!_graph.TryGetValue(reference.Target, out var target))
+ {
+ return Refuse(subject, $"schema reference '{reference.Target}' is missing");
+ }
+
+ if (!aliases.Add(reference.Target))
+ {
+ return Refuse(subject, $"non-nominal schema alias cycle reaches '{reference.Target}'");
+ }
+
+ var result = BindCore(target, subject, aliases);
+ _ = aliases.Remove(reference.Target);
+ return result;
+ }
+
+ private static NamedTypeReferencePlan BindPrimitive(PrimitiveNode primitive) => primitive.Kind switch
+ {
+ PrimitiveKind.String when string.Equals(primitive.Format, "uri", StringComparison.Ordinal) => Named("Uri"),
+ PrimitiveKind.String => Named("string"),
+ PrimitiveKind.Number => Named("double"),
+ PrimitiveKind.Integer => Named("long"),
+ PrimitiveKind.Boolean => Named("bool"),
+ _ => throw new InvalidOperationException($"Unknown primitive kind '{primitive.Kind}'."),
+ };
+
+ private ListTypeReferencePlan? BindArray(ArrayNode array, string subject, HashSet aliases)
+ {
+ var item = BindCore(array.Item, subject, aliases);
+ return item is null ? null : ListOf(item);
+ }
+
+ private DictionaryTypeReferencePlan? BindDictionary(DictionaryNode dictionary, string subject, HashSet aliases)
+ {
+ var value = BindCore(dictionary.Value, subject, aliases);
+ return value is null ? null : DictionaryOf(value);
+ }
+
+ private TypeReferencePlan? BindNullable(NullableNode nullable, string subject, HashSet aliases)
+ {
+ var inner = BindCore(nullable.Inner, subject, aliases);
+ return inner is null ? null : inner with { IsNullable = true };
+ }
+
+ private TypeReferencePlan? Refuse(string subject, string problem)
+ {
+ _errors.Add(BindingErrorCategory.Schema, subject, problem);
+ return null;
+ }
+
+ private static NamedTypeReferencePlan Named(string name) =>
+ new()
+ {
+ Name = name,
+ IsNullable = false,
+ };
+
+ private static ListTypeReferencePlan ListOf(TypeReferencePlan elementType) =>
+ new()
+ {
+ ElementType = elementType,
+ IsNullable = false,
+ };
+
+ private static DictionaryTypeReferencePlan DictionaryOf(TypeReferencePlan valueType) =>
+ new()
+ {
+ ValueType = valueType,
+ IsNullable = false,
+ };
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs
new file mode 100644
index 00000000..97d329a3
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/SpecBinder.cs
@@ -0,0 +1,81 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Abstractions;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Binding;
+
+internal sealed class SpecBinder(
+ ReachableSchemaCollector reachableSchemas,
+ CurationValidator curationValidator,
+ SchemaPlanBinder schemaPlans) : ISpecBinder
+{
+ private readonly CurationValidator _curationValidator = curationValidator ?? throw new ArgumentNullException(nameof(curationValidator));
+ private readonly ReachableSchemaCollector _reachableSchemas = reachableSchemas ?? throw new ArgumentNullException(nameof(reachableSchemas));
+ private readonly SchemaPlanBinder _schemaPlans = schemaPlans ?? throw new ArgumentNullException(nameof(schemaPlans));
+
+ public EmitPlan Bind(SpecDocument document, OperationSelection selection, GenerationCuration curation)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ ArgumentNullException.ThrowIfNull(selection);
+ ArgumentNullException.ThrowIfNull(curation);
+
+ var errors = new BindingErrorCollector();
+ var operationsById = document.Operations.ToDictionary(static operation => operation.OperationId, StringComparer.Ordinal);
+ var selected = SelectOperations(selection, operationsById, errors);
+ var reachable = _reachableSchemas.Collect(document, selected, errors);
+ _curationValidator.Validate(document, selected, reachable, curation, errors);
+ var schemaResult = _schemaPlans.Bind(document, reachable, curation, errors);
+ var selectedIds = selected.Select(static operation => operation.OperationId).ToHashSet(StringComparer.Ordinal);
+ var pending = document.Operations
+ .Where(operation => !selectedIds.Contains(operation.OperationId))
+ .OrderBy(static operation => operation.Surface)
+ .ThenBy(static operation => operation.OperationId, StringComparer.Ordinal)
+ .Select(static operation => new PendingOperationPlan
+ {
+ OperationId = operation.OperationId,
+ Surface = operation.Surface,
+ })
+ .ToArray();
+
+ errors.ThrowIfAny();
+ return new EmitPlan
+ {
+ SelectedOperationIds = selection.OperationIds,
+ Models = schemaResult.Models,
+ Unions = schemaResult.Unions,
+ Registry = schemaResult.Registry,
+ PendingOperations = pending,
+ };
+ }
+
+ private static System.Collections.ObjectModel.ReadOnlyCollection SelectOperations(OperationSelection selection,
+ Dictionary operationsById, BindingErrorCollector errors)
+ {
+ var selected = new List(selection.OperationIds.Count);
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var operationId in selection.OperationIds)
+ {
+ if (string.IsNullOrWhiteSpace(operationId))
+ {
+ errors.Add(BindingErrorCategory.Selection, "profile", "selected operation ID cannot be blank");
+ continue;
+ }
+
+ if (!seen.Add(operationId))
+ {
+ errors.Add(BindingErrorCategory.Selection, operationId, "selected operation ID is duplicated");
+ continue;
+ }
+
+ if (!operationsById.TryGetValue(operationId, out var operation))
+ {
+ errors.Add(BindingErrorCategory.Selection, operationId, "selected operation does not exist in the ingested spec");
+ continue;
+ }
+
+ selected.Add(operation);
+ }
+
+ return Array.AsReadOnly([.. selected]);
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs
new file mode 100644
index 00000000..74d130fa
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/EmissionSyntax.cs
@@ -0,0 +1,161 @@
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace OpenCode.Sdk.Tools.Generator.Emission;
+
+internal static class EmissionSyntax
+{
+ private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false);
+ private static readonly SyntaxTrivia LineFeed = SyntaxFactory.EndOfLine("\n");
+
+ public static GeneratedSource CreateSource(string relativePath, CompilationUnitSyntax compilationUnit)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
+ ArgumentNullException.ThrowIfNull(compilationUnit);
+
+ var normalized = compilationUnit.NormalizeWhitespace(indentation: " ", eol: "\n", elasticTrivia: false).ToFullString();
+ if (normalized.Length is 0 || normalized[^1] is not '\n')
+ {
+ normalized = string.Concat(normalized, "\n");
+ }
+
+ return new GeneratedSource
+ {
+ RelativePath = relativePath,
+ Utf8Source = Utf8.GetBytes(normalized),
+ };
+ }
+
+ public static CompilationUnitSyntax CompilationUnit(string namespaceName, IReadOnlyList usingNames,
+ IReadOnlyList members)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(namespaceName);
+ ArgumentNullException.ThrowIfNull(usingNames);
+ ArgumentNullException.ThrowIfNull(members);
+
+ var namespaceDeclaration = SyntaxFactory.FileScopedNamespaceDeclaration(QualifiedName(namespaceName))
+ .WithMembers(SyntaxFactory.List(members));
+ var compilationUnit = SyntaxFactory.CompilationUnit()
+ .WithUsings(SyntaxFactory.List(usingNames
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(static name => name is "System" || name.StartsWith("System.", StringComparison.Ordinal) ? 0 : 1)
+ .ThenBy(static name => name, StringComparer.Ordinal)
+ .Select(static name => SyntaxFactory.UsingDirective(QualifiedName(name)))))
+ .WithMembers(SyntaxFactory.SingletonList(namespaceDeclaration));
+
+ return compilationUnit.WithLeadingTrivia(
+ SyntaxFactory.Comment("// Generated by OpenCode.Sdk.Tools from spec/openapi.json."),
+ LineFeed,
+ SyntaxFactory.Comment("// Do not edit by hand — change tools/curation.json or the emitters, then regenerate."),
+ LineFeed,
+ LineFeed);
+ }
+
+ public static NameSyntax QualifiedName(string dottedName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(dottedName);
+ var segments = dottedName.Split('.', StringSplitOptions.RemoveEmptyEntries);
+ NameSyntax result = SyntaxFactory.IdentifierName(segments[0]);
+ for (var index = 1; index < segments.Length; index++)
+ {
+ result = SyntaxFactory.QualifiedName(result, SyntaxFactory.IdentifierName(segments[index]));
+ }
+
+ return result;
+ }
+
+ public static SyntaxTriviaList Documentation(string summary)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(summary);
+ var normalized = NormalizeDocumentation(summary);
+ var text = string.Concat("/// \n/// ", EscapeXml(normalized), "\n/// \n");
+ return SyntaxFactory.ParseLeadingTrivia(text);
+ }
+
+ public static AttributeListSyntax Attribute(string name, params AttributeArgumentSyntax[] arguments)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentNullException.ThrowIfNull(arguments);
+ var attribute = SyntaxFactory.Attribute(QualifiedName(name));
+ if (arguments.Length > 0)
+ {
+ attribute = attribute.WithArgumentList(SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(arguments)));
+ }
+
+ return SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute));
+ }
+
+ public static AttributeArgumentSyntax StringArgument(string value) =>
+ SyntaxFactory.AttributeArgument(SyntaxFactory.LiteralExpression(
+ SyntaxKind.StringLiteralExpression,
+ SyntaxFactory.Literal(value ?? throw new ArgumentNullException(nameof(value)))));
+
+ public static MemberAccessExpressionSyntax MemberAccess(ExpressionSyntax expression, string memberName) =>
+ SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ expression ?? throw new ArgumentNullException(nameof(expression)),
+ SyntaxFactory.IdentifierName(memberName));
+
+ public static MemberAccessExpressionSyntax MemberAccess(ExpressionSyntax expression, SimpleNameSyntax memberName) =>
+ SyntaxFactory.MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ expression ?? throw new ArgumentNullException(nameof(expression)),
+ memberName ?? throw new ArgumentNullException(nameof(memberName)));
+
+ public static InvocationExpressionSyntax Invocation(ExpressionSyntax expression, params ArgumentSyntax[] arguments) =>
+ SyntaxFactory.InvocationExpression(expression ?? throw new ArgumentNullException(nameof(expression)))
+ .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)));
+
+ public static IReadOnlyList ArgumentNullGuard(string parameterName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
+ var parameter = SyntaxFactory.IdentifierName(parameterName);
+ var guard = SyntaxFactory.ExpressionStatement(Invocation(
+ MemberAccess(SyntaxFactory.IdentifierName("ArgumentNullException"), "ThrowIfNull"),
+ SyntaxFactory.Argument(parameter)));
+ return Array.AsReadOnly([guard]);
+ }
+
+ public static IReadOnlyList ArgumentNullOrEmptyGuard(string parameterName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
+ var parameter = SyntaxFactory.IdentifierName(parameterName);
+ var guard = SyntaxFactory.ExpressionStatement(Invocation(
+ MemberAccess(SyntaxFactory.IdentifierName("ArgumentException"), "ThrowIfNullOrEmpty"),
+ SyntaxFactory.Argument(parameter)));
+ return Array.AsReadOnly([guard]);
+ }
+
+ private static string NormalizeDocumentation(string value)
+ {
+ var result = new StringBuilder(value.Length);
+ var previousWasWhitespace = false;
+ foreach (var character in value)
+ {
+ if (char.IsWhiteSpace(character))
+ {
+ if (!previousWasWhitespace && result.Length > 0)
+ {
+ _ = result.Append(' ');
+ }
+
+ previousWasWhitespace = true;
+ continue;
+ }
+
+ _ = result.Append(character);
+ previousWasWhitespace = false;
+ }
+
+ return result.ToString().Trim();
+ }
+
+ private static string EscapeXml(string value) => value
+ .Replace("&", "&", StringComparison.Ordinal)
+ .Replace("<", "<", StringComparison.Ordinal)
+ .Replace(">", ">", StringComparison.Ordinal)
+ .Replace("\"", """, StringComparison.Ordinal)
+ .Replace("'", "'", StringComparison.Ordinal);
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/GeneratedSource.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/GeneratedSource.cs
new file mode 100644
index 00000000..f0c52d10
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/GeneratedSource.cs
@@ -0,0 +1,20 @@
+namespace OpenCode.Sdk.Tools.Generator.Emission;
+
+internal sealed record GeneratedSource
+{
+ public required string RelativePath
+ {
+ get;
+ init
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(value);
+ field = value;
+ }
+ }
+
+ public required ReadOnlyMemory Utf8Source
+ {
+ get;
+ init => field = value.ToArray();
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs
new file mode 100644
index 00000000..3bd9b46d
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ModelEmitter.cs
@@ -0,0 +1,353 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Emission;
+
+internal static class ModelEmitter
+{
+ public static IReadOnlyList Emit(EmitPlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ var unions = plan.Unions.ToDictionary(static union => union.Name, StringComparer.Ordinal);
+ 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)),
+ EnumModelPlan enumModel => EmitEnum(enumModel),
+ _ => throw new InvalidOperationException($"Unknown model plan '{model.GetType().Name}'."),
+ });
+ }
+
+ return Array.AsReadOnly([.. result]);
+ }
+
+ private static GeneratedSource EmitObject(ObjectModelPlan model, UnionPlan? baseUnion)
+ {
+ if (baseUnion is not null && model.Properties.Count(property => IsDiscriminator(property, baseUnion)) is not 1)
+ {
+ throw new InvalidOperationException($"Union variant '{model.Name}' must carry exactly one '{baseUnion.MarkerWireName}' marker.");
+ }
+
+ var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), model.Name)
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword),
+ SyntaxFactory.Token(SyntaxKind.SealedKeyword)))
+ .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken))
+ .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken))
+ .WithMembers(SyntaxFactory.List(model.Properties.Select(property =>
+ EmitProperty(property, baseUnion is not null && IsDiscriminator(property, baseUnion)))))
+ .WithLeadingTrivia(EmissionSyntax.Documentation(model.Description ?? $"Represents a {DisplayName(model.Name)} value."));
+ if (model.BaseTypeName is not null)
+ {
+ declaration = declaration.WithBaseList(SyntaxFactory.BaseList(
+ SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed(model.BaseTypeName)))));
+ }
+
+ var unit = EmissionSyntax.CompilationUnit(model.Namespace, CollectUsings(model), [declaration]);
+ return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit);
+ }
+
+ private static GeneratedSource EmitEnum(EnumModelPlan model)
+ {
+ var converterType = TypeSyntaxEmitter.Generic("JsonStringEnumConverter", TypeSyntaxEmitter.EmitNamed(model.Name));
+ var declaration = SyntaxFactory.EnumDeclaration(model.Name)
+ .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
+ .AddAttributeLists(EmissionSyntax.Attribute("JsonConverter", SyntaxFactory.AttributeArgument(
+ SyntaxFactory.TypeOfExpression(converterType))))
+ .WithMembers(SyntaxFactory.SeparatedList(model.Values.Select(static value => SyntaxFactory.EnumMemberDeclaration(value.Name)
+ .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]);
+ return EmissionSyntax.CreateSource($"Models/{model.Name}.cs", unit);
+ }
+
+ private static PropertyDeclarationSyntax EmitProperty(ModelPropertyPlan property, bool isDiscriminator)
+ {
+ var declaration = SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.Emit(property.Type), property.Name)
+ .AddAttributeLists(EmissionSyntax.Attribute("JsonPropertyName", EmissionSyntax.StringArgument(property.WireName)))
+ .WithLeadingTrivia(EmissionSyntax.Documentation(property.Description ?? $"Gets the {DisplayName(property.Name)} value."));
+ if (property.Type.IsNullable)
+ {
+ declaration = declaration.AddAttributeLists(EmissionSyntax.Attribute(
+ "JsonIgnore",
+ SyntaxFactory.AttributeArgument(EmissionSyntax.MemberAccess(
+ SyntaxFactory.IdentifierName("JsonIgnoreCondition"),
+ "WhenWritingNull"))
+ .WithNameEquals(SyntaxFactory.NameEquals("Condition"))));
+ }
+
+ if (isDiscriminator)
+ {
+ return declaration
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword),
+ SyntaxFactory.Token(SyntaxKind.OverrideKeyword)))
+ .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(EmitLiteral(property)))
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken));
+ }
+
+ var modifiers = new List { SyntaxFactory.Token(SyntaxKind.PublicKeyword), };
+ if (property.IsRequired)
+ {
+ modifiers.Add(SyntaxFactory.Token(SyntaxKind.RequiredKeyword));
+ }
+
+ declaration = declaration
+ .WithModifiers(SyntaxFactory.TokenList(modifiers))
+ .WithAccessorList(property.Type.IsCollection
+ ? EmitCollectionAccessors(property.Type, property.IsRequired)
+ : EmitAutoAccessors());
+ var initializer = EmitCollectionInitializer(property.Type);
+ return initializer is null
+ ? declaration
+ : declaration.WithInitializer(SyntaxFactory.EqualsValueClause(initializer))
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken));
+ }
+
+ private static AccessorListSyntax EmitAutoAccessors() => SyntaxFactory.AccessorList(SyntaxFactory.List(
+ [
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration)
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
+ ]));
+
+ private static AccessorListSyntax EmitCollectionAccessors(TypeReferencePlan type, bool isRequired)
+ {
+ var copy = EmitCollectionCopy(type, SyntaxFactory.IdentifierName("value"));
+ StatementSyntax assignment;
+ var statements = new List();
+ if (type.IsNullable)
+ {
+ assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ SyntaxFactory.IdentifierName("field"),
+ SyntaxFactory.ConditionalExpression(
+ SyntaxFactory.IsPatternExpression(
+ SyntaxFactory.IdentifierName("value"),
+ SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))),
+ SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression),
+ copy)));
+ }
+ else if (!isRequired)
+ {
+ var empty = EmitCollectionInitializer(type)
+ ?? throw new InvalidOperationException("A non-null collection plan has no empty initializer.");
+ assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ SyntaxFactory.IdentifierName("field"),
+ SyntaxFactory.ConditionalExpression(
+ SyntaxFactory.IsPatternExpression(
+ SyntaxFactory.IdentifierName("value"),
+ SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))),
+ empty,
+ copy)))
+ .WithLeadingTrivia(
+ SyntaxFactory.Comment("// Source-generated deserialization passes null for an absent optional init-only collection."),
+ SyntaxFactory.EndOfLine("\n"));
+ }
+ else
+ {
+ statements.AddRange(EmissionSyntax.ArgumentNullGuard("value"));
+ assignment = SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ SyntaxFactory.IdentifierName("field"),
+ copy));
+ }
+
+ statements.Add(assignment);
+ return SyntaxFactory.AccessorList(SyntaxFactory.List(
+ [
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.InitAccessorDeclaration)
+ .WithBody(SyntaxFactory.Block(statements)),
+ ]));
+ }
+
+ private static ExpressionSyntax? EmitCollectionInitializer(TypeReferencePlan type)
+ {
+ if (!type.IsCollection || type.IsNullable)
+ {
+ return null;
+ }
+
+ return type switch
+ {
+ ListTypeReferencePlan list => EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(
+ SyntaxFactory.IdentifierName("Array"),
+ TypeSyntaxEmitter.Generic("Empty", TypeSyntaxEmitter.Emit(list.ElementType)))),
+ DictionaryTypeReferencePlan dictionary => EmitEmptyDictionary(dictionary),
+ _ => throw new InvalidOperationException($"Unknown collection plan '{type.GetType().Name}'."),
+ };
+ }
+
+ private static ExpressionSyntax EmitCollectionCopy(TypeReferencePlan type, ExpressionSyntax value) => type switch
+ {
+ ListTypeReferencePlan list => EmitListCopy(list, value),
+ DictionaryTypeReferencePlan dictionary => EmitDictionaryCopy(dictionary, value),
+ _ => throw new InvalidOperationException($"Unknown collection plan '{type.GetType().Name}'."),
+ };
+
+ private static InvocationExpressionSyntax EmitListCopy(ListTypeReferencePlan list, ExpressionSyntax value)
+ {
+ var source = list.ElementType.IsCollection
+ ? EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(value, "Select"),
+ SyntaxFactory.Argument(Projection(
+ "element",
+ SyntaxFactory.CastExpression(
+ TypeSyntaxEmitter.Emit(list.ElementType),
+ EmitNestedCollectionCopy(list.ElementType, SyntaxFactory.IdentifierName("element"))))))
+ : value;
+ return EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(
+ SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.Generic("List", TypeSyntaxEmitter.Emit(list.ElementType)))
+ .WithArgumentList(SyntaxFactory.ArgumentList(
+ SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(source)))),
+ "AsReadOnly"));
+ }
+
+ private static ObjectCreationExpressionSyntax EmitDictionaryCopy(DictionaryTypeReferencePlan dictionary, ExpressionSyntax value)
+ {
+ var pair = SyntaxFactory.IdentifierName("pair");
+ var mutableCopy = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(value, "ToDictionary"),
+ SyntaxFactory.Argument(Projection("pair", EmissionSyntax.MemberAccess(pair, "Key"))),
+ SyntaxFactory.Argument(Projection(
+ "pair",
+ dictionary.ValueType.IsCollection
+ ? SyntaxFactory.CastExpression(
+ TypeSyntaxEmitter.Emit(dictionary.ValueType),
+ EmitNestedCollectionCopy(dictionary.ValueType, EmissionSyntax.MemberAccess(pair, "Value")))
+ : EmissionSyntax.MemberAccess(pair, "Value"))),
+ SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal")));
+ var readOnlyType = TypeSyntaxEmitter.Generic(
+ "ReadOnlyDictionary",
+ SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)),
+ TypeSyntaxEmitter.Emit(dictionary.ValueType));
+ return SyntaxFactory.ObjectCreationExpression(readOnlyType)
+ .WithArgumentList(SyntaxFactory.ArgumentList(
+ SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(mutableCopy))));
+ }
+
+ private static ExpressionSyntax EmitNestedCollectionCopy(TypeReferencePlan type, ExpressionSyntax value)
+ {
+ var copy = EmitCollectionCopy(type, value);
+ return type.IsNullable
+ ? SyntaxFactory.ConditionalExpression(
+ SyntaxFactory.IsPatternExpression(
+ value,
+ SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))),
+ SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression),
+ copy)
+ : copy;
+ }
+
+ private static SimpleLambdaExpressionSyntax Projection(string parameterName, ExpressionSyntax expression) =>
+ SyntaxFactory.SimpleLambdaExpression(
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameterName)),
+ expression)
+ .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.StaticKeyword)));
+
+ private static ObjectCreationExpressionSyntax EmitEmptyDictionary(DictionaryTypeReferencePlan dictionary)
+ {
+ var dictionaryType = TypeSyntaxEmitter.Generic(
+ "Dictionary",
+ SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)),
+ TypeSyntaxEmitter.Emit(dictionary.ValueType));
+ var empty = SyntaxFactory.ObjectCreationExpression(dictionaryType)
+ .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal")))));
+ var readOnlyType = TypeSyntaxEmitter.Generic(
+ "ReadOnlyDictionary",
+ SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)),
+ TypeSyntaxEmitter.Emit(dictionary.ValueType));
+ return SyntaxFactory.ObjectCreationExpression(readOnlyType)
+ .WithArgumentList(SyntaxFactory.ArgumentList(
+ SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(empty))));
+ }
+
+ private static LiteralExpressionSyntax EmitLiteral(ModelPropertyPlan property) => property.LiteralKind switch
+ {
+ LiteralKind.String => SyntaxFactory.LiteralExpression(
+ SyntaxKind.StringLiteralExpression,
+ SyntaxFactory.Literal(property.LiteralValue ?? throw new InvalidOperationException("String literal had no value."))),
+ LiteralKind.Boolean when bool.TryParse(property.LiteralValue, out var value) => SyntaxFactory.LiteralExpression(
+ value ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression),
+ _ => throw new InvalidOperationException($"Property '{property.Name}' has an invalid literal plan."),
+ };
+
+ private static IReadOnlyList CollectUsings(ObjectModelPlan model)
+ {
+ var result = new HashSet(StringComparer.Ordinal);
+ if (model.Properties.Count > 0)
+ {
+ _ = result.Add("System.Text.Json.Serialization");
+ }
+
+ foreach (var property in model.Properties)
+ {
+ CollectTypeUsings(property.Type, result);
+ }
+
+ return [.. result.Order(StringComparer.Ordinal)];
+ }
+
+ private static void CollectTypeUsings(TypeReferencePlan type, ISet usings)
+ {
+ switch (type)
+ {
+ case NamedTypeReferencePlan { Name: "Uri" }:
+ _ = usings.Add("System");
+ break;
+ case NamedTypeReferencePlan { Name: "JsonElement" }:
+ _ = usings.Add("System.Text.Json");
+ break;
+ case ListTypeReferencePlan list:
+ _ = usings.Add("System");
+ _ = usings.Add("System.Collections.Generic");
+ if (list.ElementType.IsCollection)
+ {
+ _ = usings.Add("System.Linq");
+ }
+
+ CollectTypeUsings(list.ElementType, usings);
+ break;
+ case DictionaryTypeReferencePlan dictionary:
+ _ = usings.Add("System");
+ _ = usings.Add("System.Collections.Generic");
+ _ = usings.Add("System.Collections.ObjectModel");
+ _ = usings.Add("System.Linq");
+ CollectTypeUsings(dictionary.ValueType, usings);
+ break;
+ }
+ }
+
+ private static UnionPlan? ResolveBaseUnion(ObjectModelPlan model, Dictionary unions)
+ {
+ if (model.BaseTypeName is null)
+ {
+ return null;
+ }
+
+ return unions.TryGetValue(model.BaseTypeName, out var union)
+ ? union
+ : throw new InvalidOperationException($"Model '{model.Name}' references absent union '{model.BaseTypeName}'.");
+ }
+
+ private static bool IsDiscriminator(ModelPropertyPlan property, UnionPlan union) =>
+ property.IsLiteral && string.Equals(property.WireName, union.MarkerWireName, StringComparison.Ordinal);
+
+ private static string DisplayName(string name) =>
+ string.Join(' ', CSharpNamePolicy.SplitWords(name).Select(static word => word.ToLowerInvariant()));
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs
new file mode 100644
index 00000000..d4232da3
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/RegistryEmitter.cs
@@ -0,0 +1,43 @@
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Emission;
+
+internal static class RegistryEmitter
+{
+ public static IReadOnlyList Emit(RegistryPlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ var declaration = SyntaxFactory.ClassDeclaration("OpenCodeJsonContext")
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.InternalKeyword),
+ SyntaxFactory.Token(SyntaxKind.SealedKeyword),
+ SyntaxFactory.Token(SyntaxKind.PartialKeyword)))
+ .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.SimpleBaseType(SyntaxFactory.IdentifierName("JsonSerializerContext")))))
+ .AddAttributeLists(EmissionSyntax.Attribute(
+ "JsonSourceGenerationOptions",
+ SyntaxFactory.AttributeArgument(EmissionSyntax.MemberAccess(
+ SyntaxFactory.IdentifierName("JsonSourceGenerationMode"),
+ "Metadata"))
+ .WithNameEquals(SyntaxFactory.NameEquals("GenerationMode")),
+ SyntaxFactory.AttributeArgument(SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression))
+ .WithNameEquals(SyntaxFactory.NameEquals("RespectNullableAnnotations"))));
+ foreach (var typeName in plan.TypeNames.Order(StringComparer.Ordinal))
+ {
+ declaration = declaration.AddAttributeLists(EmissionSyntax.Attribute(
+ "JsonSerializable",
+ SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.EmitNamed(typeName)))));
+ }
+
+ var unit = EmissionSyntax.CompilationUnit(
+ "OpenCode.Sdk.Internal.Serialization",
+ ["OpenCode.Sdk.Models", "System.Text.Json.Serialization"],
+ [declaration]);
+ return Array.AsReadOnly(
+ [
+ EmissionSyntax.CreateSource("Internal/Serialization/OpenCodeJsonContext.cs", unit),
+ ]);
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs
new file mode 100644
index 00000000..dab9b9fd
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/SourceEmitter.cs
@@ -0,0 +1,24 @@
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Emission;
+
+internal static class SourceEmitter
+{
+ public static IReadOnlyList Emit(EmitPlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ var sources = new List();
+ sources.AddRange(ModelEmitter.Emit(plan));
+ sources.AddRange(UnionEmitter.Emit(plan.Unions));
+ sources.AddRange(RegistryEmitter.Emit(plan.Registry));
+
+ var duplicate = sources.GroupBy(static source => source.RelativePath, StringComparer.Ordinal)
+ .FirstOrDefault(static group => group.Count() > 1);
+ if (duplicate is not null)
+ {
+ throw new InvalidOperationException($"Multiple emitters produced '{duplicate.Key}'.");
+ }
+
+ return Array.AsReadOnly([.. sources.OrderBy(static source => source.RelativePath, StringComparer.Ordinal)]);
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/TypeSyntaxEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/TypeSyntaxEmitter.cs
new file mode 100644
index 00000000..f340b0b8
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/TypeSyntaxEmitter.cs
@@ -0,0 +1,54 @@
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Emission;
+
+internal static class TypeSyntaxEmitter
+{
+ public static TypeSyntax Emit(TypeReferencePlan plan)
+ {
+ ArgumentNullException.ThrowIfNull(plan);
+ var type = plan switch
+ {
+ NamedTypeReferencePlan named => EmitNamed(named.Name),
+ ListTypeReferencePlan list => Generic("IReadOnlyList", Emit(list.ElementType)),
+ DictionaryTypeReferencePlan dictionary => Generic(
+ "IReadOnlyDictionary",
+ SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)),
+ Emit(dictionary.ValueType)),
+ _ => throw new InvalidOperationException($"Unknown type-reference plan '{plan.GetType().Name}'."),
+ };
+
+ return plan.IsNullable ? SyntaxFactory.NullableType(type) : type;
+ }
+
+ public static TypeSyntax EmitMarker(LiteralKind kind) => kind switch
+ {
+ LiteralKind.String => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)),
+ LiteralKind.Boolean => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)),
+ _ => throw new InvalidOperationException($"Unknown literal kind '{kind}'."),
+ };
+
+ public static TypeSyntax EmitNamed(string name)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ return name switch
+ {
+ "bool" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)),
+ "double" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.DoubleKeyword)),
+ "long" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.LongKeyword)),
+ "string" => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)),
+ _ => EmissionSyntax.QualifiedName(name),
+ };
+ }
+
+ public static GenericNameSyntax Generic(string name, params TypeSyntax[] arguments)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentNullException.ThrowIfNull(arguments);
+ return SyntaxFactory.GenericName(SyntaxFactory.Identifier(name))
+ .WithTypeArgumentList(SyntaxFactory.TypeArgumentList(SyntaxFactory.SeparatedList(arguments)));
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs
new file mode 100644
index 00000000..efaf6e01
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/UnionEmitter.cs
@@ -0,0 +1,442 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+
+namespace OpenCode.Sdk.Tools.Generator.Emission;
+
+internal static class UnionEmitter
+{
+ public static IReadOnlyList Emit(IReadOnlyList unions)
+ {
+ ArgumentNullException.ThrowIfNull(unions);
+ var result = new List(unions.Count * 3);
+ foreach (var union in unions.OrderBy(static union => union.Name, StringComparer.Ordinal))
+ {
+ result.Add(EmitBase(union));
+ result.Add(EmitUnknown(union));
+ result.Add(EmitConverter(union));
+ }
+
+ return Array.AsReadOnly([.. result]);
+ }
+
+ private static GeneratedSource EmitBase(UnionPlan union)
+ {
+ var converterTypeName = string.Concat(union.Name, "JsonConverter");
+ var marker = SyntaxFactory.PropertyDeclaration(TypeSyntaxEmitter.EmitMarker(union.MarkerKind), union.MarkerName)
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword),
+ SyntaxFactory.Token(SyntaxKind.AbstractKeyword)))
+ .AddAttributeLists(EmissionSyntax.Attribute("JsonPropertyName", EmissionSyntax.StringArgument(union.MarkerWireName)))
+ .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.SingletonList(
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)))))
+ .WithLeadingTrivia(EmissionSyntax.Documentation($"Gets the '{union.MarkerWireName}' union marker."));
+ var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), union.Name)
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword),
+ SyntaxFactory.Token(SyntaxKind.AbstractKeyword)))
+ .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken))
+ .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken))
+ .AddAttributeLists(EmissionSyntax.Attribute(
+ "JsonConverter",
+ SyntaxFactory.AttributeArgument(SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.EmitNamed(converterTypeName)))))
+ .WithMembers(SyntaxFactory.SingletonList(marker))
+ .WithLeadingTrivia(EmissionSyntax.Documentation(union.Description ?? $"Represents a {DisplayName(union.Name)} union."));
+ var unit = EmissionSyntax.CompilationUnit(
+ union.Namespace,
+ ["OpenCode.Sdk.Internal.Serialization", "System.Text.Json.Serialization"],
+ [declaration]);
+ return EmissionSyntax.CreateSource($"Models/{union.Name}.cs", unit);
+ }
+
+ private static GeneratedSource EmitUnknown(UnionPlan union)
+ {
+ var markerType = TypeSyntaxEmitter.EmitMarker(union.MarkerKind);
+ var members = new MemberDeclarationSyntax[]
+ {
+ EmitUnknownMarkerField(markerType),
+ EmitUnknownConstructor(union, markerType),
+ EmitUnknownMarkerProperty(union, markerType),
+ EmitUnknownPayloadProperty(),
+ };
+ var declaration = SyntaxFactory.RecordDeclaration(SyntaxFactory.Token(SyntaxKind.RecordKeyword), union.UnknownTypeName)
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword),
+ SyntaxFactory.Token(SyntaxKind.SealedKeyword)))
+ .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken))
+ .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken))
+ .WithBaseList(SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.SimpleBaseType(TypeSyntaxEmitter.EmitNamed(union.Name)))))
+ .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"];
+ var unit = EmissionSyntax.CompilationUnit(union.Namespace, usingNames, [declaration]);
+ return EmissionSyntax.CreateSource($"Models/{union.UnknownTypeName}.cs", unit);
+ }
+
+ private static FieldDeclarationSyntax EmitUnknownMarkerField(TypeSyntax markerType) =>
+ SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration(markerType)
+ .WithVariables(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("_marker"))))
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PrivateKeyword),
+ SyntaxFactory.Token(SyntaxKind.ReadOnlyKeyword)));
+
+ private static ConstructorDeclarationSyntax EmitUnknownConstructor(UnionPlan union, TypeSyntax markerType)
+ {
+ var markerParameterName = CSharpNamePolicy.ToCamelCase(union.MarkerName);
+ var statements = new List();
+ if (union.MarkerKind is LiteralKind.String)
+ {
+ statements.AddRange(EmissionSyntax.ArgumentNullOrEmptyGuard(markerParameterName));
+ }
+
+ statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ SyntaxFactory.IdentifierName("_marker"),
+ SyntaxFactory.IdentifierName(markerParameterName))));
+ statements.Add(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ SyntaxFactory.IdentifierName("Payload"),
+ EmissionSyntax.Invocation(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "Clone")))));
+ return SyntaxFactory.ConstructorDeclaration(union.UnknownTypeName)
+ .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
+ .AddAttributeLists(EmissionSyntax.Attribute("JsonConstructor"))
+ .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(
+ [
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier(markerParameterName)).WithType(markerType),
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("payload")).WithType(SyntaxFactory.IdentifierName("JsonElement")),
+ ])))
+ .WithBody(SyntaxFactory.Block(statements))
+ .WithLeadingTrivia(EmissionSyntax.Documentation("Initializes an unknown union value from its marker and raw payload."));
+ }
+
+ private static PropertyDeclarationSyntax EmitUnknownMarkerProperty(UnionPlan union, TypeSyntax markerType) =>
+ SyntaxFactory.PropertyDeclaration(markerType, union.MarkerName)
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword),
+ SyntaxFactory.Token(SyntaxKind.OverrideKeyword)))
+ .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(SyntaxFactory.IdentifierName("_marker")))
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))
+ .WithLeadingTrivia(EmissionSyntax.Documentation($"Gets the unrecognized '{union.MarkerWireName}' marker."));
+
+ private static PropertyDeclarationSyntax EmitUnknownPayloadProperty() =>
+ SyntaxFactory.PropertyDeclaration(SyntaxFactory.IdentifierName("JsonElement"), "Payload")
+ .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
+ .WithAccessorList(SyntaxFactory.AccessorList(SyntaxFactory.SingletonList(
+ SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)))))
+ .WithLeadingTrivia(EmissionSyntax.Documentation("Gets the preserved raw JSON payload."));
+
+ private static GeneratedSource EmitConverter(UnionPlan union)
+ {
+ var converterName = string.Concat(union.Name, "JsonConverter");
+ 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.Name))))))
+ .WithMembers(SyntaxFactory.List(
+ [
+ EmitDispatchMap(union),
+ EmitHandleNull(),
+ EmitRead(union),
+ EmitWrite(union),
+ ]));
+ var unit = EmissionSyntax.CompilationUnit(
+ "OpenCode.Sdk.Internal.Serialization",
+ [
+ "OpenCode.Sdk.Models",
+ "System",
+ "System.Collections.Generic",
+ "System.Text.Json",
+ "System.Text.Json.Serialization",
+ ],
+ [declaration]);
+ return EmissionSyntax.CreateSource($"Internal/Serialization/{converterName}.cs", unit);
+ }
+
+ private static FieldDeclarationSyntax EmitDispatchMap(UnionPlan union)
+ {
+ var markerType = TypeSyntaxEmitter.EmitMarker(union.MarkerKind);
+ var dictionaryType = TypeSyntaxEmitter.Generic("Dictionary", markerType, SyntaxFactory.IdentifierName("Type"));
+ var entries = union.Variants.OrderBy(static variant => variant.Tag, StringComparer.Ordinal)
+ .Select(variant => (ExpressionSyntax)SyntaxFactory.AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ SyntaxFactory.ImplicitElementAccess(SyntaxFactory.BracketedArgumentList(
+ SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(EmitMarkerLiteral(union.MarkerKind, variant.Tag))))),
+ SyntaxFactory.TypeOfExpression(TypeSyntaxEmitter.EmitNamed(variant.TypeName))))
+ .ToArray();
+ var initializer = SyntaxFactory.ObjectCreationExpression(dictionaryType)
+ .WithArgumentList(union.MarkerKind is LiteralKind.String
+ ? SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("StringComparer"), "Ordinal"))))
+ : SyntaxFactory.ArgumentList())
+ .WithInitializer(SyntaxFactory.InitializerExpression(
+ SyntaxKind.CollectionInitializerExpression,
+ SyntaxFactory.SeparatedList(entries)));
+ return SyntaxFactory.FieldDeclaration(SyntaxFactory.VariableDeclaration(dictionaryType)
+ .WithVariables(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.VariableDeclarator("TypesByTag")
+ .WithInitializer(SyntaxFactory.EqualsValueClause(initializer)))))
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PrivateKeyword),
+ SyntaxFactory.Token(SyntaxKind.StaticKeyword),
+ SyntaxFactory.Token(SyntaxKind.ReadOnlyKeyword)));
+ }
+
+ private static PropertyDeclarationSyntax EmitHandleNull() =>
+ SyntaxFactory.PropertyDeclaration(
+ SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.BoolKeyword)),
+ "HandleNull")
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword),
+ SyntaxFactory.Token(SyntaxKind.OverrideKeyword)))
+ .WithExpressionBody(SyntaxFactory.ArrowExpressionClause(
+ SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)))
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken));
+
+ private static MethodDeclarationSyntax EmitRead(UnionPlan union)
+ {
+ var statements = new List();
+ statements.AddRange(EmissionSyntax.ArgumentNullGuard("typeToConvert"));
+ 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.")),
+ EmitPayloadDocument(),
+ Local("payload", EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("document"), "RootElement")),
+ EmitObjectPayloadCheck(union),
+ EmitMarkerPresenceCheck(union),
+ ]);
+ statements.AddRange(EmitMarkerRead(union));
+ statements.Add(EmitKnownDispatch(union));
+ statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.ObjectCreationExpression(
+ SyntaxFactory.IdentifierName(union.UnknownTypeName))
+ .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(
+ [
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("marker")),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")),
+ ])))));
+
+ return SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(union.Name), "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(statements));
+ }
+
+ private static LocalDeclarationStatementSyntax EmitPayloadDocument()
+ {
+ var parse = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonDocument"), "ParseValue"),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("reader"))
+ .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.RefKeyword)));
+ return SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration(SyntaxFactory.IdentifierName("var"))
+ .WithVariables(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.VariableDeclarator("document").WithInitializer(SyntaxFactory.EqualsValueClause(parse)))))
+ .WithUsingKeyword(SyntaxFactory.Token(SyntaxKind.UsingKeyword));
+ }
+
+ private static IfStatementSyntax EmitObjectPayloadCheck(UnionPlan union) =>
+ SyntaxFactory.IfStatement(
+ SyntaxFactory.BinaryExpression(
+ SyntaxKind.NotEqualsExpression,
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "ValueKind"),
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "Object")),
+ ThrowJson($"The {union.Name} payload must be a JSON object."));
+
+ private static IfStatementSyntax EmitMarkerPresenceCheck(UnionPlan union)
+ {
+ var tryGet = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("payload"), "TryGetProperty"),
+ SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(
+ SyntaxKind.StringLiteralExpression,
+ SyntaxFactory.Literal(union.MarkerWireName))),
+ SyntaxFactory.Argument(SyntaxFactory.DeclarationExpression(
+ SyntaxFactory.IdentifierName("var"),
+ SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("markerElement"))))
+ .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.OutKeyword)));
+ return SyntaxFactory.IfStatement(
+ SyntaxFactory.PrefixUnaryExpression(SyntaxKind.LogicalNotExpression, tryGet),
+ ThrowJson($"The {union.Name} payload must contain '{union.MarkerWireName}'."));
+ }
+
+ private static IReadOnlyList EmitMarkerRead(UnionPlan union) => union.MarkerKind switch
+ {
+ LiteralKind.String => EmitStringMarkerRead(union),
+ LiteralKind.Boolean => EmitBooleanMarkerRead(union),
+ _ => throw new InvalidOperationException($"Unknown marker kind '{union.MarkerKind}'."),
+ };
+
+ private static IReadOnlyList EmitStringMarkerRead(UnionPlan union) =>
+ [
+ SyntaxFactory.IfStatement(
+ SyntaxFactory.BinaryExpression(
+ SyntaxKind.NotEqualsExpression,
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "ValueKind"),
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "String")),
+ ThrowJson($"The '{union.MarkerWireName}' marker must be a string.")),
+ Local("marker", EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "GetString"))),
+ SyntaxFactory.IfStatement(
+ SyntaxFactory.IsPatternExpression(
+ SyntaxFactory.IdentifierName("marker"),
+ SyntaxFactory.ConstantPattern(SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression))),
+ ThrowJson($"The '{union.MarkerWireName}' marker cannot be null.")),
+ ];
+
+ private static IReadOnlyList EmitBooleanMarkerRead(UnionPlan union)
+ {
+ var valueKind = EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "ValueKind");
+ return
+ [
+ SyntaxFactory.IfStatement(
+ SyntaxFactory.BinaryExpression(
+ SyntaxKind.LogicalAndExpression,
+ SyntaxFactory.BinaryExpression(
+ SyntaxKind.NotEqualsExpression,
+ valueKind,
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "True")),
+ SyntaxFactory.BinaryExpression(
+ SyntaxKind.NotEqualsExpression,
+ valueKind,
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonValueKind"), "False"))),
+ ThrowJson($"The '{union.MarkerWireName}' marker must be a boolean.")),
+ Local("marker", EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("markerElement"), "GetBoolean"))),
+ ];
+ }
+
+ private static IfStatementSyntax EmitKnownDispatch(UnionPlan union)
+ {
+ var tryGet = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("TypesByTag"), "TryGetValue"),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("marker")),
+ SyntaxFactory.Argument(SyntaxFactory.DeclarationExpression(
+ SyntaxFactory.IdentifierName("var"),
+ SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("targetType"))))
+ .WithRefKindKeyword(SyntaxFactory.Token(SyntaxKind.OutKeyword)));
+ var getTypeInfo = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"),
+ "GetTypeInfo"),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("targetType")));
+ var typeInfo = SyntaxFactory.BinaryExpression(
+ SyntaxKind.CoalesceExpression,
+ getTypeInfo,
+ SyntaxFactory.ThrowExpression(JsonException($"The generated context has no metadata for {union.Name}.")));
+ var deserialize = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonSerializer"), "Deserialize"),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("payload")),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("typeInfo")));
+ var result = SyntaxFactory.BinaryExpression(
+ SyntaxKind.CoalesceExpression,
+ SyntaxFactory.BinaryExpression(
+ SyntaxKind.AsExpression,
+ deserialize,
+ TypeSyntaxEmitter.EmitNamed(union.Name)),
+ SyntaxFactory.ThrowExpression(JsonException($"The {union.Name} payload deserialized to null.")));
+ return SyntaxFactory.IfStatement(tryGet, SyntaxFactory.Block(
+ Local("typeInfo", typeInfo),
+ SyntaxFactory.ReturnStatement(result)));
+ }
+
+ private static MethodDeclarationSyntax EmitWrite(UnionPlan union)
+ {
+ var unknownPattern = SyntaxFactory.IsPatternExpression(
+ SyntaxFactory.IdentifierName("value"),
+ SyntaxFactory.DeclarationPattern(
+ SyntaxFactory.IdentifierName(union.UnknownTypeName),
+ SyntaxFactory.SingleVariableDesignation(SyntaxFactory.Identifier("unknown"))));
+ var writeUnknown = SyntaxFactory.Block(
+ SyntaxFactory.ExpressionStatement(EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("unknown"), "Payload"),
+ "WriteTo"),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("writer")))),
+ SyntaxFactory.ReturnStatement());
+ var getRuntimeType = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("value"), "GetType"));
+ var getTypeInfo = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("OpenCodeJsonContext"), "Default"),
+ "GetTypeInfo"),
+ SyntaxFactory.Argument(getRuntimeType));
+ var typeInfo = SyntaxFactory.BinaryExpression(
+ SyntaxKind.CoalesceExpression,
+ getTypeInfo,
+ SyntaxFactory.ThrowExpression(JsonException($"The generated context has no metadata for {union.Name}.")));
+ var serialize = EmissionSyntax.Invocation(
+ EmissionSyntax.MemberAccess(SyntaxFactory.IdentifierName("JsonSerializer"), "Serialize"),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("writer")),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("value")),
+ SyntaxFactory.Argument(SyntaxFactory.IdentifierName("typeInfo")));
+ var statements = new List();
+ statements.AddRange(EmissionSyntax.ArgumentNullGuard("writer"));
+ statements.AddRange(EmissionSyntax.ArgumentNullGuard("value"));
+ statements.AddRange(EmissionSyntax.ArgumentNullGuard("options"));
+ statements.Add(SyntaxFactory.IfStatement(unknownPattern, writeUnknown));
+ statements.Add(Local("typeInfo", typeInfo));
+ statements.Add(SyntaxFactory.ExpressionStatement(serialize));
+ return 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.Name)),
+ SyntaxFactory.Parameter(SyntaxFactory.Identifier("options")).WithType(SyntaxFactory.IdentifierName("JsonSerializerOptions")),
+ ])))
+ .WithBody(SyntaxFactory.Block(statements));
+ }
+
+ private static LocalDeclarationStatementSyntax Local(string name, ExpressionSyntax value) =>
+ SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration(SyntaxFactory.IdentifierName("var"))
+ .WithVariables(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.VariableDeclarator(name).WithInitializer(SyntaxFactory.EqualsValueClause(value)))));
+
+ private static ThrowStatementSyntax ThrowJson(string message) => SyntaxFactory.ThrowStatement(JsonException(message));
+
+ private static ObjectCreationExpressionSyntax JsonException(string message) =>
+ SyntaxFactory.ObjectCreationExpression(SyntaxFactory.IdentifierName("JsonException"))
+ .WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(
+ SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(
+ SyntaxKind.StringLiteralExpression,
+ SyntaxFactory.Literal(message))))));
+
+ private static LiteralExpressionSyntax EmitMarkerLiteral(LiteralKind kind, string value) => kind switch
+ {
+ LiteralKind.String => SyntaxFactory.LiteralExpression(
+ SyntaxKind.StringLiteralExpression,
+ SyntaxFactory.Literal(value)),
+ LiteralKind.Boolean when bool.TryParse(value, out var boolean) => SyntaxFactory.LiteralExpression(
+ boolean ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression),
+ _ => throw new InvalidOperationException($"Marker value '{value}' is invalid for '{kind}'."),
+ };
+
+ private static string DisplayName(string name) =>
+ string.Join(' ', CSharpNamePolicy.SplitWords(name).Select(static word => word.ToLowerInvariant()));
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs
new file mode 100644
index 00000000..a27c09e4
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationCoordinator.cs
@@ -0,0 +1,83 @@
+using System.Globalization;
+using System.Text;
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Abstractions;
+using OpenCode.Sdk.Tools.Generator.Emission;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions;
+using OpenCode.Sdk.Tools.Generator.Ingestion.Models;
+using OpenCode.Sdk.Tools.Output.Abstractions;
+
+namespace OpenCode.Sdk.Tools.Generator;
+
+internal sealed class GenerationCoordinator(
+ ISpecIngestion ingestion,
+ OperationSelectionLoader selectionLoader,
+ CurationLoader curationLoader,
+ ISpecBinder binder,
+ IGenerationWriter writer)
+{
+ private readonly ISpecBinder _binder = binder ?? throw new ArgumentNullException(nameof(binder));
+ private readonly CurationLoader _curationLoader = curationLoader ?? throw new ArgumentNullException(nameof(curationLoader));
+ private readonly ISpecIngestion _ingestion = ingestion ?? throw new ArgumentNullException(nameof(ingestion));
+ private readonly OperationSelectionLoader _selectionLoader = selectionLoader ?? throw new ArgumentNullException(nameof(selectionLoader));
+ private readonly IGenerationWriter _writer = writer ?? throw new ArgumentNullException(nameof(writer));
+
+ public async Task GenerateAsync(GenerationRequest request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var document = await _ingestion.IngestAsync(request.SpecPath, cancellationToken).ConfigureAwait(false);
+ var selection = await _selectionLoader.LoadAsync(request.ProfilePath, cancellationToken).ConfigureAwait(false);
+ var curation = await _curationLoader.LoadAsync(request.CurationPath, cancellationToken).ConfigureAwait(false);
+ var plan = _binder.Bind(document, selection, curation);
+ var modern = plan.PendingOperations
+ .Where(static operation => operation.Surface == SpecSurface.Modern)
+ .Select(static operation => operation.OperationId)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+ var legacy = plan.PendingOperations
+ .Where(static operation => operation.Surface == SpecSurface.Legacy)
+ .Select(static operation => operation.OperationId)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+ var marker = modern.Length + legacy.Length > 0 ? CreatePartialMarker(plan.SelectedOperationIds, modern, legacy) : null;
+ var writeResult = await _writer.WriteAsync(
+ request.OutputRoot,
+ request.ProjectPath,
+ SourceEmitter.Emit(plan),
+ marker,
+ request.Verify,
+ cancellationToken).ConfigureAwait(false);
+ return new GenerationReport
+ {
+ SelectedOperationIds = plan.SelectedOperationIds,
+ PendingModernOperationIds = modern,
+ PendingLegacyOperationIds = legacy,
+ WriteResult = writeResult,
+ };
+ }
+
+ private static string CreatePartialMarker(IReadOnlyList selected, string[] modern, string[] legacy)
+ {
+ var content = new StringBuilder()
+ .AppendLine("Generation is incomplete; packages must not be published.")
+ .Append("Selected operations: ")
+ .AppendLine(selected.Count.ToString(CultureInfo.InvariantCulture))
+ .Append("Pending modern operations: ")
+ .AppendLine(modern.Length.ToString(CultureInfo.InvariantCulture))
+ .Append("Pending legacy operations: ")
+ .AppendLine(legacy.Length.ToString(CultureInfo.InvariantCulture));
+ AppendOperations(content, "Selected", selected);
+ AppendOperations(content, "Pending modern", modern);
+ AppendOperations(content, "Pending legacy", legacy);
+ return content.ToString().ReplaceLineEndings("\n");
+ }
+
+ private static void AppendOperations(StringBuilder content, string heading, IEnumerable operationIds)
+ {
+ _ = content.Append(heading).AppendLine(":");
+ foreach (var operationId in operationIds)
+ {
+ _ = content.Append("- ").AppendLine(operationId);
+ }
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationReport.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationReport.cs
new file mode 100644
index 00000000..091a74f5
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationReport.cs
@@ -0,0 +1,38 @@
+using OpenCode.Sdk.Tools.Output;
+
+namespace OpenCode.Sdk.Tools.Generator;
+
+internal sealed record GenerationReport
+{
+ public required IReadOnlyList SelectedOperationIds
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required IReadOnlyList PendingModernOperationIds
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required IReadOnlyList PendingLegacyOperationIds
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required WriteResult WriteResult { get; init; }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Generator/GenerationRequest.cs b/tools/OpenCode.Sdk.Tools/Generator/GenerationRequest.cs
new file mode 100644
index 00000000..69a9ee31
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Generator/GenerationRequest.cs
@@ -0,0 +1,27 @@
+namespace OpenCode.Sdk.Tools.Generator;
+
+internal sealed record GenerationRequest
+{
+ public required string SpecPath { get; init; }
+
+ public required string ProfilePath { get; init; }
+
+ public required string CurationPath { get; init; }
+
+ public required string OutputRoot { get; init; }
+
+ public required string ProjectPath { get; init; }
+
+ public required bool Verify { get; init; }
+
+ public static GenerationRequest RepositoryDefault(bool verify) =>
+ new()
+ {
+ SpecPath = "spec/openapi.json",
+ ProfilePath = "tools/generation-profile.txt",
+ CurationPath = "tools/curation.json",
+ OutputRoot = "src/OpenCode.Sdk",
+ ProjectPath = "src/OpenCode.Sdk/OpenCode.Sdk.csproj",
+ Verify = verify,
+ };
+}
diff --git a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj
index 7932a5da..635f4a8f 100644
--- a/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj
+++ b/tools/OpenCode.Sdk.Tools/OpenCode.Sdk.Tools.csproj
@@ -8,6 +8,8 @@
+
+
diff --git a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs
new file mode 100644
index 00000000..96b1a9cd
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IGenerationWriter.cs
@@ -0,0 +1,14 @@
+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);
+}
diff --git a/tools/OpenCode.Sdk.Tools/Output/Abstractions/IProjectFormatter.cs b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IProjectFormatter.cs
new file mode 100644
index 00000000..d7842181
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Output/Abstractions/IProjectFormatter.cs
@@ -0,0 +1,9 @@
+namespace OpenCode.Sdk.Tools.Output.Abstractions;
+
+internal interface IProjectFormatter
+{
+ public Task FormatAsync(
+ string projectPath,
+ IReadOnlyList sourcePaths,
+ CancellationToken cancellationToken);
+}
diff --git a/tools/OpenCode.Sdk.Tools/Output/CliWrapProjectFormatter.cs b/tools/OpenCode.Sdk.Tools/Output/CliWrapProjectFormatter.cs
new file mode 100644
index 00000000..99408966
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Output/CliWrapProjectFormatter.cs
@@ -0,0 +1,46 @@
+using System.IO.Abstractions;
+using CliWrap;
+using OpenCode.Sdk.Tools.Output.Abstractions;
+
+namespace OpenCode.Sdk.Tools.Output;
+
+internal sealed class CliWrapProjectFormatter(IFileSystem fileSystem) : IProjectFormatter
+{
+ private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
+
+ public async Task FormatAsync(
+ string projectPath,
+ IReadOnlyList sourcePaths,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
+ ArgumentNullException.ThrowIfNull(sourcePaths);
+ if (sourcePaths.Count is 0)
+ {
+ return;
+ }
+
+ if (sourcePaths.Any(static sourcePath => string.IsNullOrWhiteSpace(sourcePath)))
+ {
+ throw new ArgumentException("Source paths cannot contain null or whitespace entries.", nameof(sourcePaths));
+ }
+
+ var fullProjectPath = _fileSystem.Path.GetFullPath(projectPath);
+ var projectDirectory = _fileSystem.Path.GetDirectoryName(fullProjectPath)
+ ?? throw new InvalidOperationException($"Project path '{projectPath}' has no parent directory.");
+ var projectFileName = _fileSystem.Path.GetFileName(fullProjectPath);
+
+ _ = await Cli.Wrap("dotnet")
+ .WithArguments(arguments => arguments
+ .Add("format")
+ .Add(projectFileName)
+ .Add("--no-restore")
+ .Add("--include")
+ .Add(sourcePaths))
+ .WithWorkingDirectory(projectDirectory)
+ .WithEnvironmentVariables(environment => environment.Set("TargetFramework", "net10.0"))
+ .WithValidation(CommandResultValidation.ZeroExitCode)
+ .ExecuteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationManifest.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationManifest.cs
new file mode 100644
index 00000000..36a64ca8
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Output/GenerationManifest.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+
+namespace OpenCode.Sdk.Tools.Output;
+
+internal sealed record GenerationManifest
+{
+ [JsonPropertyName("files")]
+ public required IReadOnlyList Files
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+}
diff --git a/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs
new file mode 100644
index 00000000..99d36b7c
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Output/GenerationWriter.cs
@@ -0,0 +1,303 @@
+using System.IO.Abstractions;
+using System.Text;
+using System.Text.Json;
+using OpenCode.Sdk.Tools.Generator.Emission;
+using OpenCode.Sdk.Tools.Output.Abstractions;
+using OpenCode.Sdk.Tools.Serialization;
+
+namespace OpenCode.Sdk.Tools.Output;
+
+internal sealed class GenerationWriter(IFileSystem fileSystem, IProjectFormatter formatter) : IGenerationWriter
+{
+ private const string ManifestRelativePath = ".generated-manifest.json";
+ private const string MarkerRelativePath = ".generation-incomplete";
+ private static readonly StringComparer OwnedPathComparer = StringComparer.OrdinalIgnoreCase;
+ 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)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
+ ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
+ ArgumentNullException.ThrowIfNull(sources);
+ if (partialMarkerContent is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(partialMarkerContent);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var newSources = ValidateSources(outputRoot, sources);
+ var oldManifest = await LoadManifestAsync(outputRoot, cancellationToken).ConfigureAwait(false);
+ var oldPaths = ValidateManifest(outputRoot, oldManifest);
+ RefuseCaseOnlyPathChanges(oldPaths, newSources.Keys);
+ var trackedPaths = oldPaths
+ .Concat(newSources.Keys)
+ .Append(ManifestRelativePath)
+ .Append(MarkerRelativePath)
+ .Distinct(OwnedPathComparer)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+ var before = await SnapshotAsync(outputRoot, trackedPaths, cancellationToken).ConfigureAwait(false);
+
+ 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);
+ var sourcePaths = newSources.Keys.Order(StringComparer.Ordinal).ToArray();
+ await _formatter.FormatAsync(projectPath, sourcePaths, cancellationToken).ConfigureAwait(false);
+
+ var after = await SnapshotAsync(outputRoot, trackedPaths, cancellationToken).ConfigureAwait(false);
+ return Compare(before, after, verify);
+ }
+
+ private Dictionary ValidateSources(string outputRoot, IReadOnlyList sources)
+ {
+ var result = new Dictionary(OwnedPathComparer);
+ foreach (var source in sources)
+ {
+ if (source is null)
+ {
+ throw new ArgumentException("Generated sources cannot contain null entries.", nameof(sources));
+ }
+
+ ValidateRelativePath(outputRoot, source.RelativePath);
+ ValidateGeneratedSourcePath(source.RelativePath);
+
+ if (!result.TryAdd(source.RelativePath, source))
+ {
+ throw new InvalidOperationException($"Generated source path '{source.RelativePath}' is duplicated.");
+ }
+ }
+
+ return result;
+ }
+
+ private async Task LoadManifestAsync(string outputRoot, CancellationToken cancellationToken)
+ {
+ var path = ResolveOwnedPath(outputRoot, ManifestRelativePath);
+ if (!_fileSystem.File.Exists(path))
+ {
+ return new GenerationManifest { Files = [], };
+ }
+
+ try
+ {
+ var json = await _fileSystem.File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(json, ToolJsonContext.Default.GenerationManifest)
+ ?? throw new InvalidOperationException("The generation manifest cannot be JSON null.");
+ }
+ catch (JsonException exception)
+ {
+ throw new InvalidOperationException("The generation manifest is invalid.", exception);
+ }
+ }
+
+ private HashSet ValidateManifest(string outputRoot, GenerationManifest manifest)
+ {
+ var result = new HashSet(OwnedPathComparer);
+ foreach (var relativePath in manifest.Files)
+ {
+ ValidateRelativePath(outputRoot, relativePath);
+ ValidateGeneratedSourcePath(relativePath);
+ if (!result.Add(relativePath))
+ {
+ throw new InvalidOperationException($"Generation manifest path '{relativePath}' is duplicated.");
+ }
+ }
+
+ return result;
+ }
+
+ private async Task> SnapshotAsync(string outputRoot, IReadOnlyList relativePaths,
+ CancellationToken cancellationToken)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var relativePath in relativePaths)
+ {
+ var path = ResolveOwnedPath(outputRoot, relativePath);
+ result.Add(
+ relativePath,
+ _fileSystem.File.Exists(path)
+ ? await _fileSystem.File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false)
+ : null);
+ }
+
+ return result;
+ }
+
+ private async Task WriteSourcesAsync(string outputRoot, Dictionary sources,
+ CancellationToken cancellationToken)
+ {
+ foreach (var (relativePath, source) in sources.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
+ {
+ var path = ResolveOwnedPath(outputRoot, relativePath);
+ EnsureParentDirectory(path);
+ await _fileSystem.File.WriteAllBytesAsync(path, source.Utf8Source.ToArray(), cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private void DeleteStaleSources(string outputRoot, IReadOnlySet oldPaths, IEnumerable newPaths)
+ {
+ var retained = newPaths.ToHashSet(OwnedPathComparer);
+ foreach (var relativePath in oldPaths.Where(path => !retained.Contains(path)).Order(StringComparer.Ordinal))
+ {
+ var path = ResolveOwnedPath(outputRoot, relativePath);
+ if (_fileSystem.File.Exists(path))
+ {
+ _fileSystem.File.Delete(path);
+ }
+ }
+ }
+
+ private async Task WriteManifestAsync(string outputRoot, IEnumerable paths, CancellationToken cancellationToken)
+ {
+ var manifest = new GenerationManifest
+ {
+ Files = [.. paths.Order(StringComparer.Ordinal)],
+ };
+ var json = string.Concat(JsonSerializer.Serialize(manifest, ToolJsonContext.Default.GenerationManifest), "\n");
+ await WriteBytesAsync(
+ ResolveOwnedPath(outputRoot, ManifestRelativePath),
+ Encoding.UTF8.GetBytes(json),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task WriteMarkerAsync(string outputRoot, string? content, CancellationToken cancellationToken)
+ {
+ var path = ResolveOwnedPath(outputRoot, MarkerRelativePath);
+ if (content is null)
+ {
+ if (_fileSystem.File.Exists(path))
+ {
+ _fileSystem.File.Delete(path);
+ }
+
+ return;
+ }
+
+ await WriteBytesAsync(path, Encoding.UTF8.GetBytes(content), cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task WriteBytesAsync(string path, byte[] content, CancellationToken cancellationToken)
+ {
+ EnsureParentDirectory(path);
+ await _fileSystem.File.WriteAllBytesAsync(path, content, cancellationToken).ConfigureAwait(false);
+ }
+
+ private void EnsureParentDirectory(string path)
+ {
+ var directory = _fileSystem.Path.GetDirectoryName(path)
+ ?? throw new InvalidOperationException($"Path '{path}' has no parent directory.");
+ _fileSystem.Directory.CreateDirectory(directory);
+ }
+
+ private void ValidateRelativePath(string outputRoot, string relativePath)
+ {
+ _ = ResolveOwnedPath(outputRoot, relativePath);
+ }
+
+ private static void ValidateGeneratedSourcePath(string relativePath)
+ {
+ if (!relativePath.EndsWith(".cs", StringComparison.Ordinal)
+ || !(relativePath.StartsWith("Models/", StringComparison.Ordinal)
+ || relativePath.StartsWith("Internal/Serialization/", StringComparison.Ordinal)))
+ {
+ throw new InvalidOperationException($"Generated source path '{relativePath}' is outside the owned source directories.");
+ }
+ }
+
+ private string ResolveOwnedPath(string outputRoot, string relativePath)
+ {
+ if (string.IsNullOrWhiteSpace(relativePath)
+ || _fileSystem.Path.IsPathRooted(relativePath)
+ || relativePath.Contains('\\', StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"Owned path '{relativePath}' is unsafe.");
+ }
+
+ var segments = relativePath.Split('/');
+ if (segments.Any(static segment => string.IsNullOrWhiteSpace(segment) || segment is "." or ".."))
+ {
+ throw new InvalidOperationException($"Owned path '{relativePath}' is unsafe.");
+ }
+
+ var root = _fileSystem.Path.GetFullPath(outputRoot);
+ var path = _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine([root, .. segments]));
+ var prefix = string.Concat(
+ root.TrimEnd(_fileSystem.Path.DirectorySeparatorChar, _fileSystem.Path.AltDirectorySeparatorChar),
+ _fileSystem.Path.DirectorySeparatorChar);
+ var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ if (!path.StartsWith(prefix, pathComparison))
+ {
+ throw new InvalidOperationException($"Owned path '{relativePath}' escapes the output root.");
+ }
+
+ RefuseSymbolicLinks(root, segments, relativePath);
+
+ return path;
+ }
+
+ private static void RefuseCaseOnlyPathChanges(HashSet oldPaths, IEnumerable newPaths)
+ {
+ foreach (var newPath in newPaths)
+ {
+ if (oldPaths.TryGetValue(newPath, out var oldPath)
+ && !string.Equals(oldPath, newPath, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Generated source path '{newPath}' differs from owned path '{oldPath}' only by case.");
+ }
+ }
+ }
+
+ private void RefuseSymbolicLinks(string root, IEnumerable segments, string relativePath)
+ {
+ var current = root;
+ foreach (var segment in segments)
+ {
+ current = _fileSystem.Path.Combine(current, segment);
+ if (_fileSystem.FileInfo.New(current).LinkTarget is not null)
+ {
+ throw new InvalidOperationException($"Owned path '{relativePath}' contains a symbolic link.");
+ }
+ }
+ }
+
+ private static WriteResult Compare(Dictionary before, Dictionary after, bool verify)
+ {
+ var created = new List();
+ var changed = new List();
+ var deleted = new List();
+ foreach (var relativePath in before.Keys.Order(StringComparer.Ordinal))
+ {
+ var oldContent = before[relativePath];
+ var newContent = after[relativePath];
+ if (oldContent is null && newContent is not null)
+ {
+ created.Add(relativePath);
+ }
+ else if (oldContent is not null && newContent is null)
+ {
+ deleted.Add(relativePath);
+ }
+ else if (oldContent is not null && newContent is not null && !oldContent.AsSpan().SequenceEqual(newContent))
+ {
+ changed.Add(relativePath);
+ }
+ }
+
+ return new WriteResult
+ {
+ IsVerification = verify,
+ CreatedPaths = created,
+ ChangedPaths = changed,
+ DeletedPaths = deleted,
+ };
+ }
+}
diff --git a/tools/OpenCode.Sdk.Tools/Output/WriteResult.cs b/tools/OpenCode.Sdk.Tools/Output/WriteResult.cs
new file mode 100644
index 00000000..35159d5a
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Output/WriteResult.cs
@@ -0,0 +1,38 @@
+namespace OpenCode.Sdk.Tools.Output;
+
+internal sealed record WriteResult
+{
+ public required bool IsVerification { get; init; }
+
+ public required IReadOnlyList CreatedPaths
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required IReadOnlyList ChangedPaths
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public required IReadOnlyList DeletedPaths
+ {
+ get;
+ init
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ field = Array.AsReadOnly([.. value]);
+ }
+ } = Array.AsReadOnly(Array.Empty());
+
+ public bool HasChanges => CreatedPaths.Count > 0 || ChangedPaths.Count > 0 || DeletedPaths.Count > 0;
+}
diff --git a/tools/OpenCode.Sdk.Tools/Serialization/ToolJsonContext.cs b/tools/OpenCode.Sdk.Tools/Serialization/ToolJsonContext.cs
new file mode 100644
index 00000000..3d5f3335
--- /dev/null
+++ b/tools/OpenCode.Sdk.Tools/Serialization/ToolJsonContext.cs
@@ -0,0 +1,17 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenCode.Sdk.Tools.Generator.Binding.Models;
+using OpenCode.Sdk.Tools.Output;
+
+namespace OpenCode.Sdk.Tools.Serialization;
+
+[JsonSourceGenerationOptions(
+ AllowTrailingCommas = true,
+ GenerationMode = JsonSourceGenerationMode.Metadata,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ UseStringEnumConverter = true,
+ WriteIndented = true)]
+[JsonSerializable(typeof(GenerationCuration))]
+[JsonSerializable(typeof(GenerationManifest))]
+internal sealed partial class ToolJsonContext : JsonSerializerContext;
diff --git a/tools/OpenCode.Sdk.Tools/ToolApp.cs b/tools/OpenCode.Sdk.Tools/ToolApp.cs
index e13cc6c5..e2564c6b 100644
--- a/tools/OpenCode.Sdk.Tools/ToolApp.cs
+++ b/tools/OpenCode.Sdk.Tools/ToolApp.cs
@@ -2,10 +2,15 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenCode.Sdk.Tools.Commands;
+using OpenCode.Sdk.Tools.Generator;
+using OpenCode.Sdk.Tools.Generator.Binding;
+using OpenCode.Sdk.Tools.Generator.Binding.Abstractions;
using OpenCode.Sdk.Tools.Generator.Ingestion;
using OpenCode.Sdk.Tools.Generator.Ingestion.Abstractions;
using OpenCode.Sdk.Tools.Infrastructure;
using OpenCode.Sdk.Tools.Infrastructure.Logging;
+using OpenCode.Sdk.Tools.Output;
+using OpenCode.Sdk.Tools.Output.Abstractions;
using Spectre.Console;
using Spectre.Console.Cli;
using Spectre.Console.Cli.Extensions.DependencyInjection;
@@ -27,6 +32,16 @@ public static ServiceCollection CreateServices(Action? overr
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton