Skip to content

feat(sdk): land the M1 callable client - #16

Merged
Blind-Striker merged 28 commits into
masterfrom
feature/m1-callable-client
Aug 13, 2026
Merged

Blind-Striker merged 28 commits into
masterfrom
feature/m1-callable-client

Conversation

@Blind-Striker

@Blind-Striker Blind-Striker commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

M1 — walking skeleton, complete

This branch closes M1: the SDK goes from a compiled model closure (#15) to a callable client demonstrated live against a real server. It also executes the v2 retarget sealed in ADR-0005 — the generation target moves to upstream's packages/protocol/openapi.json on the v2 branch, pinned at commit a6a712a, and the legacy 1.x surface is never built.

What landed

  • v2 retarget — pin swap to v2-branch a6a712a (104 paths / 120 operations / 324 schemas), the single-element validation-only allOf admit rule in the ingestion wall, and the regenerated model closure.
  • Callable surface, fully generated (ADR-0008) — SpecIR plus curation bind into client/operation/envelope/route/error plans; generic emitters render OpenCodeClient.GetHealthAsync, Sessions.GetSessionClient(...), SessionClient.GetMessageAsync, guarded envelopes, OpenCodeRoutes (nested per-group containers, template + escaped-builder pairs), and response adapters as committed source. Emitters stay name-blind; names ride the plan.
  • Hand-written behavior core — one Pipeline owns endpoint authority, Basic-auth/User-Agent decoration, response buffering, throw-versus-NoThrow, and transport-failure mapping over the ADR-0007 exception spine (OpenCodeException → OpenCodeApiException; transport/protocol failures as OpenCodeTransportException).
  • Mechanical naming policies — envelope payload names derive mechanically from operation subject tokens with a fail-closed spine-collision check (envelopePayloadNames is an override ledger for collisions only); the User-Agent version token strips + build metadata and omits itself entirely rather than falling back silently.
  • Public API locked — a reviewed PublicApiGenerator baseline shared across TFMs; the generation writer refuses unmanifested overwrites and headerless manifest entries.
  • Tests — 526 across the tooling and SDK legs (net8.0/net9.0/net10.0 everywhere; the net472 leg runs on Windows), including a contract matrix over the callable surface.
  • Sandbox — tests/OpenCode.Sdk.Sandbox, a committed debug playground for driving the SDK against a real opencode2 serve under a debugger.

Packing still fails deliberately on the .generation-incomplete marker — the generation profile is two operations wide; packaging unblocks when the surface completes (M5).

Live demonstration (2026-08-13)

Server: opencode2 v0.0.0-next-17403 (@opencode-ai/cli@next); spec pin: v2-branch a6a712a.

$ opencode2 serve --hostname 127.0.0.1 --port 41733
server listening on http://127.0.0.1:41733
server password <generated>

$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:41733/api/health
401

# IDs obtained through the pinned surface (v2.session.list, v2.message.list):
$ curl -su "opencode:<pw>" "http://127.0.0.1:41733/api/session?limit=1" | jq -r '.data[0].id'
ses_00e0d1436ffe86YL3QKbrn3Ldy
$ curl -su "opencode:<pw>" ".../api/session/ses_00e0d1436ffe86YL3QKbrn3Ldy/message" | jq -r '.data[0].id'
msg_ffb0e86fa001n28S9q0u7F7JxM

# The two generated methods, from a scratch console with a ProjectReference to OpenCode.Sdk:
$ dotnet run -- http://127.0.0.1:41733 <pw> ses_00e0d1436ffe86YL3QKbrn3Ldy msg_ffb0e86fa001n28S9q0u7F7JxM
health: status=200 healthy=True version=0.0.0-next-17403 pid=3910076
message: status=200 type=SessionMessageAssistant id=msg_ffb0e86fa001n28S9q0u7F7JxM

Content detail (second run):

message: status=200 type=SessionMessageAssistant
  id:      msg_ffb0e86fa001n28S9q0u7F7JxM
  agent:   build
  model:   ModelRef { Id = gpt-5.6-sol, ProviderId = openai, Variant = max }
  finish:    cost: 0  tokens: TokenUsageInfo { Input = 0, Output = 0, Reasoning = 0, Cache = TokenUsageInfoCache { Read = 0, Write = 0 } }
  content: 1 part(s)
    [reasoning] **Summarizing findings and planning next steps**

Version skew note

The demo server is opencode2 v0.0.0-next-17403 while the spec pin is v2-branch commit a6a712a — deliberately not the same snapshot. Pinned-snapshot policy (ADR-0005) is the mechanism that absorbs upstream's continuous pre-GA churn: refreshes are deliberate, at milestone boundaries (M2 is the next sanctioned moment), never HEAD-tracking.

Review follow-ups

Multi-agent review findings triaged into issues #17–#25 (milestone-anchored; see docs/ROADMAP.md). The blocker set and the performance-test infrastructure landed on this branch.

Fixes #17. Fixes #18.

Model policy (Arc B Task 1, first increment):
- CSharpNamePolicy: ordinary PascalCase for acronym tokens (Id, SessionId, Url)
- OptionalCollectionInput: absent optional collections read empty while explicit
  JSON null still throws; generated accessors route through the nullable-input
  helper; emitter, snapshot, and serialization tests added
- regenerate the selected model closure with the new casing
- analyzer-driven formatting normalization in tool sources

Direction (sealed 2026-08-13): the SDK targets the v2 protocol surface only.
- ADR-0005 revised in place (v2-protocol-surface-only); ADR-0006/0008 trimmed
- research doc 15 (v2 platform), research log session 17, docs 09/10 corrections
- ROADMAP: M5 legacy hub removed, M3/M4 re-scoped, open questions refreshed
- Arc B plan gains Task 0 (spec pin retarget); handoff and glossary updated
- v1 ledger closed: location[...] query is the only per-request scoping; no
  x-opencode-directory, no backward-compatibility debt
- schema names map mechanically; no taste curation (283/324 v2 schemas are dotted)
- the Modern/Legacy surface split retires in Task 0
Task 0 of the M1 Arc B plan (ADR-0005 as revised; platform evidence:
docs/research/15-opencode-v2-platform.md).

- pin spec/openapi.json to v2-branch commit a6a712a (the protocol
  package's openapi.json); rewrite SNAPSHOT.md provenance
- ingestion-wall admits, each fail-closed to the observed population:
  single-element validation-only allOf wrappers unwrap; single-value
  number enums project as number literals; a bare array projects as
  array-of-any; prefixItems admits exactly two shapes (closed tuple,
  NonEmptyArray encoding) and location pinning retires
- union machinery for the v2 topology: discriminating-marker selection,
  nested marked unions (SessionMessageCompaction : SessionMessageInfo
  with a fixed outer tag), promoted inline branch naming (root + marker
  value), nested special-number encoding; unknown carriers emit
  wire-faithful JsonPropertyName and nested unknowns re-serialize their
  raw payload through the declared base type
- retire the Modern/Legacy surface split: SpecSurface deleted, the
  parser requires the v2. protocol prefix fail-closed, single pending
  list in report/marker/output, legacy vocabulary swept from code and
  operational docs
- regenerate the closure from the v2 pin (stale v1 files deleted by the
  manifest writer); remap curation to v2 names
- update both test projects to the v2 surface with nested-union
  serialization coverage (221 tools + 45 SDK tests green)
Replace the consumed pre-retarget handoff with a comprehensive onboarding
handoff (reading order, Task 0 machinery, v2 wire contracts, Tasks 1-4
detail, demo procedure, stop conditions); refresh the ROADMAP status to
the executed retarget.
Groups declaring a bound handle now pair handleName with handleParameter, and
the validator checks the parameter names a required path parameter on at least
one selected operation in the group (selection-scoped, client placement only).
The hard-coded sessionID placement rule retires: a group without a handle
declaration stays flat regardless of the path parameters its operations carry.
SpecBinder owns the SchemaNameResolver call and hands the resolved map to
schema binding, so the upcoming operation binding consumes the same names
instead of reinterpreting schemas.
Consumed-only ClientPlan/OperationPlan/OperationParameterPlan/EnvelopePlan/
ErrorMapPlan land behind an OperationPlanBinder that consumes the shared
type-name map and fails closed on anything but ordinary GET + JSON +
path-parameter operations with one 200 success. Wire-contract refusals get
their own Operation error category and batch per operation.

Envelope payload names now derive mechanically from the operation subject
(Health, Message); envelopePayloadNames rows are overrides for response-spine
collisions only, and the required-per-operation validation retires
(maintainer, 2026-08-13). Groups place operations through the general
handle rule; a synthetic same-shape group binds through the same code with
zero operation-, group-, or client-specific branches.
Hand-written identity core, first slice: ErrorBehavior, client/request
options with the shared NoThrow instance, the OpenCodeResponse envelope
base, and the OpenCodeException spine carrying status, typed error, and
raw body. The environment seam (IEnvironmentProvider +
SystemEnvironmentProvider) lands for the one-time password fallback,
net472 gains its conditional System.Net.Http framework reference, and the
SDK now hides source-only polyfills behind the embedded attribute because
it declares a friend test assembly.
Pipeline.ExecuteAsync is the single home of request behavior: endpoint
validation and route joining through EndpointPolicy (absolute HTTP(S), no
query/fragment/userinfo, path prefix preserved), per-request Basic-auth and
User-Agent decoration (explicit password before the one-time
OPENCODE_SERVER_PASSWORD read; UserAgentPolicy strips build metadata and
omits the version token when unresolvable - maintainer, 2026-08-13), one
send primitive with ResponseHeadersRead, buffered bodies handed to a
synchronous ResponseAdapter, and throw-versus-NoThrow applied after
adaptation. Transport failures wrap as OpenCodeTransportException;
cancellation always passes through. Owned clients dispose with the
pipeline, injected clients never; a disposed pipeline refuses execution.
Downlevel targets gain the System.Net.Http global using implicit usings
only provide on net6+.
OperationPlan carries RouteContainerName (the client name, or the group for
root placement) and RouteMemberName (verb plus subject, never restating the
container). Containers refuse duplicate members alongside the existing
method, response, and client collision checks.
RoutesEmitter renders nested route containers (const for parameterless
operations, template const plus escaped builder otherwise); EnvelopeEmitter
renders sealed response records with a guarded payload getter, the internal
SetsRequiredMembers error constructor carrying the SDK's single
null-forgiveness, and a payload-skipping PrintMembers; ResponseAdapterEmitter
renders status-switched adapters over the hand-written tolerant-reading base
(bare and data payload readers, tag-set downgrade to the unknown carrier);
ClientEmitter plus OperationMethodEmitter render virtual members, protected
mock constructors behind the MockSeam failure, root ownership of
Pipeline.Create and Dispose, immutable handle state, and one pipeline
delegation per operation. The aggregate probe now compiles fresh emitter
output together with the hand-written core sources, replaying the production
compilation unit; SourceEmitter stays unwired until the writer admits the
new roots.
The writer now owns root-level generated files and the response-adapter
subtree alongside models and serialization. Three fail-closed rules join the
manifest-diff cleanup: a new path that already exists on disk is refused as
unmanifested, manifest entries are only overwritten or deleted while they
still carry the exact provenance header (now shared between emission and the
writer, and validated on incoming sources so a headerless emit cannot
deadlock the next run), and case-only collisions stay refused. CS1591
becomes an error outside tests, CI gains the Linux generate --verify step,
and the generated route surface's nested containers get their narrow CA1034
arbitration.
SourceEmitter now renders routes, envelopes, adapters, and clients whenever
the plan carries clients, and the pinned profile's callable surface lands as
committed source: OpenCodeClient.GetHealthAsync, Sessions.GetSessionClient,
SessionClient.GetMessageAsync, both guarded envelopes, the OpenCodeRoutes
containers, and the two response adapters. The aggregate compile probes run
through SourceEmitter and the hand-written core.
Recording-handler tests exercise the real generated clients end to end:
typed health and message successes (data envelope unwrap, escaped routes),
both declared 404 error types, default throw versus NoThrow, unknown-tag /
undeclared-status / wrong-status downgrades to the unknown carrier with the
exact tag and raw payload, malformed error bodies preserving the raw body
with a null typed error on both channels, malformed 2xx as a transport
failure, guarded payload access and printing on the error path, the
instructive mock-seam failure, bound handles going unusable on root
disposal, and the BYO endpoint requirement.
PublicApiGenerator 11.5.4 renders the SDK's public surface into one
TFM-shared Verify baseline: the thirteen root types plus the generated model
closure, with no internal type leaking. Packing still fails only on the
generation-incomplete marker.
Both generated operations ran against a real opencode2 serve
(v0.0.0-next-17403; pin a6a712a — deliberate snapshot skew): GetHealthAsync
returned the typed ServiceHealth payload and GetMessageAsync returned a
typed SessionMessageAssistant through the bound session handle. The Arc B
plan's final box is checked, the roadmap moves to M2, and the consumed
session handover is deleted.
tests/OpenCode.Sdk.Sandbox is a committed console for driving the SDK
against a real opencode2 serve under a debugger. It rides the repository's
full analyzer and formatting conventions, reads its configuration from
environment variables (launchSettings.json prefills the IDE profile), and
exercises the SDK's own OPENCODE_SERVER_PASSWORD fallback instead of
passing a password explicitly. The M1 demo code moves here from the
scratchpad; the project deliberately stays a flat console until the
Extensions package brings a host-based composition root.
The sandbox profile ships with a fixed local password and this machine's
known session/message ids; the README shows starting the server with
OPENCODE_SERVER_PASSWORD (serve reads the same variable the SDK falls back
to) and the curl calls that discover ids through the pinned list
operations.
…queue

Session 18 captures the three maintainer-sealed decisions (mechanical
envelope payload names with an override-only ledger, the strip-and-omit
User-Agent version policy, and the nested route-container shape); the new
handover queues the ship steps (maintainer-run multi-agent review, push/PR
with the demo transcript, first-contact CI watch), the proposed first M2
batch, and the horizon.
@Blind-Striker
Blind-Striker merged commit 75af30e into master Aug 13, 2026
3 checks passed
@Blind-Striker
Blind-Striker deleted the feature/m1-callable-client branch August 13, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant