diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c10e0983..0a9e79171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,56 @@ Each released version links straight to its GitHub Release tag. ## [Unreleased] -Nothing yet. Nightly builds of `master` are on +Nightly builds of `master` are on [GitHub Packages](README.md#nightly-builds-github-packages) as `0.8.0-nightly.{yyyyMMdd}.{shortSha}`. +### πŸ’₯ Breaking changes + +- **The accepted snapshot moved to upstream `20aff6d9f643afe9abf8a048e68f019d049f5329`**, the + `beta` branch head that shipped as `@opencode/cli@0.0.0-beta-19425`. Install that exact build + (`npm install -g @opencode/cli@0.0.0-beta-19425`); the `@opencode-ai/cli` scope that the + previous README named is frozen at an August build that predates the worktree route shape and the + whole persistent-PTY family this SDK generates. +- **`v2.session.messageUpdate` is gone** because upstream removed the message content mutation API + ([anomalyco/opencode#48043](https://github.com/anomalyco/opencode/pull/48043)). + `SessionClient.PatchMessageUpdateAsync`, `SessionMessageUpdatePatchRequest`, + `SessionMessageUpdatePatchResponse`, and the route constants are removed, and with them the + `ISessionMessageAssistant` union and its `UnknownSessionMessageAssistant` carrier, which only that + request body referenced. Assistant content records keep `ISessionMessageAssistantContent`. +- **`session.message.content.updated` left the live event union.** `SessionMessageContentUpdated` + no longer implements `IEvent`; it remains a durable session-log item. + +### ✨ New features + +- **Session listing enumerates too.** `SessionsClient.EnumerateSessionsAsync` joins + `SessionClient.EnumerateMessagesAsync` as an automatic cursor walk. A list query now binds the + shared `ListRequest` spine whenever it *includes* `limit`, `order`, and `cursor` β€” extra filters + no longer disqualify it β€” so `SessionListRequest` derives from `ListRequest` and gains the + companion. Every filter of the first request rides each continuation unchanged; only the + first-page-only `order` is dropped and the opaque cursor replaced. +- **Every automatic walk can be read as pages.** `Enumerate*Async` now returns + `CursorSequence`, whose `Pages` property yields each generated response envelope β€” + `Status`, `Cursor`, `RawBody` and all β€” instead of only the items. There is no new page type and + no page-size knob: a page is the response the server returned for one request. The item door and + the page door are independent walks of the same recipe, so enumerating both sends both sets of + requests. + +### πŸ”§ Changes + +- **Snapshot additions.** `MessageListRequest.Type` filters a message list by message type + (`MessageListRequestType`) and rides every continuation unchanged; `ModelInfo.Websocket` and + `ProviderInfo.Websocket` are new optional flags; compaction ended and failed data, and the + compaction message records, carry optional `Cost` and `Tokens`. +- `SessionClient.EnumerateMessagesAsync` returns `CursorSequence` instead of `IAsyncEnumerable`. `await foreach` over it + is unchanged, and code that stored the result in a variable typed `IAsyncEnumerable` still + compiles, because `CursorSequence` implements `IAsyncEnumerable`. Code that + declared the variable with `var` and then assigned an `IAsyncEnumerable` to the same variable + is the one shape that needs its type written out. +- `SessionListRequest` no longer declares `Limit`, `Order`, and `Cursor` itself; it inherits all + three from `ListRequest`. Reading and initializing them is unchanged. + ## [0.8.0-preview.1] - 2026-09-09 The first published release. Everything below describes the surface as it ships; there is no diff --git a/CONTEXT.md b/CONTEXT.md index 4dc786b81..f13eefc4e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -258,6 +258,11 @@ property is optional has no payload the envelope can promise and refuses at bind A sub-client bound to one resource id (e.g. a session) β€” partial application over the shared pipeline; never caches server state. +**Cursor sequence** (`CursorSequence`): +The value a generated `Enumerate*Async` companion returns. It enumerates as the items of every page +in order and exposes the same traversal's page envelopes through `Pages`. It is a recipe rather than +a buffer: each enumeration issues its own requests. + **PTY session** (`PtySession`): The working object over one PTY connection: read frames, write input, dispose to close. One of the two doors that build their own transport instead of riding the HTTP pipeline (the persistent PTY diff --git a/Directory.Packages.props b/Directory.Packages.props index 7707cd15b..c267013a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + diff --git a/README.md b/README.md index 7dd4942e0..8323f1964 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ hand-written transport runtime, so what you call is exactly what the server decl covered. What is still outstanding is opencode's third connection mode β€” attaching to a registered background service. -- βœ… **135 of 140 operations** callable, across 28 client families β€” sessions, PTYs, persistent +- βœ… **134 of 139 operations** callable, across 28 client families β€” sessions, PTYs, persistent PTYs, shells, events, MCP servers, integrations, providers, permissions, credentials, VCS, worktrees, websearch, RPC, and more -- βœ… **5,138 tests** green on Windows β€” the fullest leg, the only one that adds the `net472` +- βœ… **5,177 tests** green on Windows β€” the fullest leg, the only one that adds the `net472` assemblies. Linux and macOS run the same suite on `net8.0`, `net9.0`, and `net10.0` - βœ… **Server-sent event streams**, global and per-session, over the same transport as one-shot calls - βœ… **PTY and persistent-PTY terminal sessions** through hand-written WebSocket doors @@ -106,11 +106,15 @@ on, and the `net472` leg is what exercises its compile surface. ## Prerequisites -You need an `opencode` server. The v2 line ships as `@opencode-ai/cli@next` and installs a single -command, `opencode2` ([opencode.ai](https://opencode.ai)): +You need an `opencode` server. The v2 line ships from the `@opencode/cli` npm scope as `beta` +builds and installs a single command, `opencode2` ([opencode.ai](https://opencode.ai)). This SDK +is generated from the upstream commit that shipped as `0.0.0-beta-19425`, so install that exact +build; later `beta` builds usually work, but they are not what this repository tests. The pinned +commit and its build are owned by +[`spec/SNAPSHOT.md`](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/spec/SNAPSHOT.md). ```sh -npm install -g @opencode-ai/cli@next +npm install -g @opencode/cli@0.0.0-beta-19425 ``` Then either run it yourself: @@ -273,7 +277,7 @@ overload shown above. ## 🧭 API Coverage -**135 of the 140 operations** in the pinned snapshot are callable. The remaining five are not +**134 of the 139 operations** in the pinned snapshot are callable. The remaining five are not omissions β€” each one is a recorded decision with a named cause, and [`src/OpenCode.Sdk/.generation-incomplete`](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/src/OpenCode.Sdk/.generation-incomplete) is the machine-readable map that the build itself reads. @@ -305,7 +309,7 @@ hand-written `PtySession` and `PersistentPtySession` doors described in | [Streaming](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/docs/guide/streaming.md) | The global event bus and per-session server-sent event streams | | [Terminals](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/docs/guide/terminals.md) | PTY and persistent-PTY sessions over the WebSocket doors | | [Errors and responses](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/docs/guide/errors-and-responses.md) | Throwing versus `NoThrow`, and the typed error model | -| [Pagination](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/docs/guide/pagination.md) | Cursor-carrying list envelopes and `EnumerateMessagesAsync` | +| [Pagination](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/docs/guide/pagination.md) | Cursor-carrying list envelopes, `Enumerate*Async`, and its `Pages` | Architecture, decision records, and engineering policy live under [`docs/`](https://github.com/Blind-Striker/opencode-sdk-dotnet/tree/master/docs) β€” start at [`AGENTS.md`](https://github.com/Blind-Striker/opencode-sdk-dotnet/blob/master/AGENTS.md) if you want the internals rather than the API. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8074dbe44..ebe8c2551 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Date: 2026-09-08 +Date: 2026-09-12 Operational state: what ships today, what is queued next, what is still open, and what is known to be incomplete. This file is a summary and shrinks as work lands. `../AGENTS.md` routes to the @@ -15,7 +15,7 @@ accepted OpenAPI snapshot and rides one hand-written transport runtime. - **Protocol pin** β€” generation reads an accepted snapshot of upstream's `v2` OpenAPI document, never a live branch, and refreshes are receipt-governed (ADR-0020). `../spec/SNAPSHOT.md` owns the exact commit and the refresh procedure. -- **Coverage** β€” **135 of 140 operations selected** across 28 client families, with 3 declined by +- **Coverage** β€” **134 of 139 operations selected** across 28 client families, with 3 declined by decision and 2 transport-owned (Known Gaps below); `src/OpenCode.Sdk/.generation-incomplete` is the committed marker and names every one. One-shot calls, server-sent event streams (the global bus and the per-session log), PTY and persistent-PTY WebSocket sessions, cursor pagination, typed @@ -74,7 +74,7 @@ is revisited at each boundary. ## Open Questions -- **v2 GA watch** β€” the v2 line ships as `opencode2` (npm `@opencode-ai/cli@next`) with no GA date. +- **v2 GA watch** β€” the v2 line ships as `opencode2` (npm `@opencode/cli@beta`) with no GA date. The pin therefore stays a deliberate snapshot, refreshed under receipt at milestone boundaries. - **`v2.session.log` resume guarantees** β€” the pinned document exposes `after` as an optional string, and the generated surface stays faithful to it; ADR-0013 forbids importing the narrower diff --git a/docs/adr/0005-v2-protocol-surface-only.md b/docs/adr/0005-v2-protocol-surface-only.md index 9dca47e2a..0373c5d49 100644 --- a/docs/adr/0005-v2-protocol-surface-only.md +++ b/docs/adr/0005-v2-protocol-surface-only.md @@ -27,7 +27,7 @@ internal research, 2026-08-08, "Upstream v1/v2: product version vs HTTP API surf consumer-driven legacy testing all disappear; milestone M5 shrinks to completing the generation profile over the single surface. - Until upstream's v2 line reaches general availability the SDK targets a pre-release - server (`opencode2`; npm `@opencode-ai/cli@next`) β€” accepted: the M-series timeline runs + server (`opencode2`; npm `@opencode/cli@beta`) β€” accepted: the M-series timeline runs alongside upstream's stabilization, and the pinned-snapshot + fail-closed refresh machinery exists for exactly this churn. - The spec pin is a snapshot of a moving branch: refreshes stay deliberate and diff --git a/docs/adr/0017-cursor-pagination-keeps-explicit-pages.md b/docs/adr/0017-cursor-pagination-keeps-explicit-pages.md index 3eac34b15..5e2572b39 100644 --- a/docs/adr/0017-cursor-pagination-keeps-explicit-pages.md +++ b/docs/adr/0017-cursor-pagination-keeps-explicit-pages.md @@ -1,26 +1,32 @@ # Cursor pagination keeps explicit pages and adds item enumeration -Date: 2026-08-20 +Date: 2026-09-12 An operation whose pinned request and response bind to the supported cursor-list dialect keeps its -generated one-page `List*Async` method and additively emits `Enumerate*Async` as a lazy -`IAsyncEnumerable`. The page method remains the cursor/metadata and `NoThrow` path; automatic -item traversal always throws API errors when their page is reached. This was chosen over a local -`AsyncPageable`/`Page` family because the generated response envelope already is the page, -including the bidirectional `ListCursor`, while an Azure-shaped wrapper would duplicate that public -vocabulary and invite an integer page-size contract absent from the pin. +generated one-page `List*Async` method and additively emits `Enumerate*Async`, which returns a +`CursorSequence`: a lazy `IAsyncEnumerable` whose `Pages` property replays the +same traversal as page envelopes. The page method remains the `NoThrow` and `cursor.previous` path; +automatic traversal takes no per-call options and always throws API errors when their page is +reached. A local `AsyncPageable`/`Page` family was rejected and stays rejected: `TPage` is the +generated response envelope itself, including the bidirectional `ListCursor`, so the page door +introduces no second public page vocabulary and no integer page-size contract absent from the pin. -The first request is sent unchanged. Continuations retain the pinned string `limit`, omit -first-page-only `order`, and pass the opaque returned `cursor.next`; only an absent next cursor ends -the sequence. Generated adapters project each admitted operation onto one hand-written traversal -core. Other pagination dialects require their own mechanically proven binding support rather than -being inferred from descriptions or forced through `ListRequest`. +The first request is sent unchanged. A continuation is that same request with first-page-only +`order` dropped and the opaque returned `cursor.next` put in place, so the pinned string `limit` and +every filter the query declares travel every page; only an absent next cursor ends the sequence. A +query binds `ListRequest` by inclusion β€” the three admitted members must be present in their +admitted shapes, filters may ride beside them β€” and a spine-carrying query that declares a required +parameter is refused, because a continuation must be constructible from no request at all. Generated +adapters project each admitted operation onto one hand-written traversal core. Other pagination +dialects require their own mechanically proven binding support rather than being inferred from +descriptions or forced through `ListRequest`. ## Consequences - The automatic sequence is finite pull-based pagination over ordinary buffered HTTP responses, not a server stream and not a second transport path. - Cancellation is observed during page requests and between already-buffered items. -- `cursor.previous` and page metadata remain available through explicit page calls; a future page - sequence can be added without changing this surface if a concrete consumer earns it. +- `cursor.previous` and per-call `NoThrow` remain reasons to call the page method directly; page + metadata is otherwise reachable from the automatic traversal through `Pages`, which yields the + same envelopes the page method returns. - Cursors are never decoded, normalized, incremented, compared, or cycle-checked (ADR-0013). diff --git a/docs/architecture/client-runtime.md b/docs/architecture/client-runtime.md index 0824de1d9..2e867c232 100644 --- a/docs/architecture/client-runtime.md +++ b/docs/architecture/client-runtime.md @@ -1,6 +1,6 @@ # Client Runtime Architecture -Date: 2026-09-08 +Date: 2026-09-12 Canonical current rules for client construction, transport ownership, API errors, streams, and the local server launcher. Protocol and generated-model rules live in @@ -319,14 +319,21 @@ local server launcher. Protocol and generated-model rules live in ## Pagination - A supported cursor-list operation has two generated doors: `List*Async` returns one endpoint- - specific page envelope, while `Enumerate*Async` lazily yields its items across pages. Explicit - pages retain cursor/status metadata and per-call `NoThrow`; automatic item traversal has no - response envelope and therefore always throws API errors when their page is reached (ADR-0007, - ADR-0017). -- `ListRequest` carries the pinned string `limit`, first-page `order`, and opaque `cursor` channels; - `ListCursor` preserves the response's optional `previous` and `next` values. The first automatic - request is sent unchanged, including an order-plus-cursor pair. Each continuation retains the - initial `limit`, omits `order`, and sends the returned `cursor.next` without decoding it. + specific page envelope, while `Enumerate*Async` returns a `CursorSequence` that + lazily yields the items across pages and, through its `Pages` property, the same traversal's page + envelopes. Explicit pages retain per-call `NoThrow`; automatic traversal never takes per-call + options and therefore always throws API errors when their page is reached (ADR-0007, ADR-0017). +- `CursorSequence` is a recipe, not a buffer: the item door and the page door each + drive the one traversal core independently, so enumerating either performs its own requests, and + enumerating both performs both walks. Its `TPage` is the generated response type β€” no page + vocabulary and no page-size contract is introduced. The token given to `Enumerate*Async` and the + token given to `GetAsyncEnumerator`/`WithCancellation` are both observed. +- `ListRequest` carries the pinned string `limit`, first-page `order`, and opaque `cursor` channels, + and a derived request may declare its own filters beside them; `ListCursor` preserves the + response's optional `previous` and `next` values. The first automatic request is sent unchanged, + including an order-plus-cursor pair. Each continuation is that request again with `order` dropped + and the returned `cursor.next` put in place, so `limit` and every filter travel every page and a + filter added to the pinned query needs no generator change. - A missing `next` cursor is the only normal end signal. An empty page with `next` continues; `previous` remains available through explicit page calls. Cursor values are never normalized, incremented, compared, or cycle-checked. Cancellation reaches each request and is checked between diff --git a/docs/architecture/protocol-and-generation.md b/docs/architecture/protocol-and-generation.md index 91c1cb51f..14e8ff3ce 100644 --- a/docs/architecture/protocol-and-generation.md +++ b/docs/architecture/protocol-and-generation.md @@ -1,6 +1,6 @@ # Protocol and Generation Architecture -Date: 2026-09-03 +Date: 2026-09-12 Canonical current rules for the protocol surface, generator, generated models, and runtime materialization boundary. ADRs record why these decisions were made; dated research records the @@ -191,9 +191,14 @@ dispatch instead of routing it through ADR-0009's unknown carrier (ADR-0015). the declared-header channel into the pipeline. The header value stays a caller concern and never enters curation or generated code, and a header-bearing operation forgoes the cursor-enumeration companion (ADR-0013, ADR-0021). +- A query binds the `ListRequest` base by inclusion: it must carry the admitted optional `limit`, + `order`, and `cursor` parameters in their admitted shapes, and every further optional parameter + binds beside them as its own request property. A missing spine member, or one whose schema is not + the admitted shape, keeps the request flat and unpaginated. - A cursor-list operation emits an additive `Enumerate*Async` companion only when binding proves the - admitted `ListRequest` query, `ListCursor` response, item collection, and operation signature. - Generated response adapters project page items, the opaque next cursor, and the continuation + admitted `ListRequest` query, `ListCursor` response, item collection, and operation signature, and + the query declares no required parameter β€” a continuation must be constructible from no request at + all. Generated response adapters project page items, the opaque next cursor, and the continuation request into one hand-written traversal core. Method names, descriptions, and upstream source do not independently confer pagination semantics (ADR-0013, ADR-0017). - A query parameter binds by shape: a required parameter becomes a `required`, non-nullable diff --git a/docs/guide/pagination.md b/docs/guide/pagination.md index ad5ff4350..03e793bbd 100644 --- a/docs/guide/pagination.md +++ b/docs/guide/pagination.md @@ -1,15 +1,16 @@ # πŸ“‘ Pagination -Date: 2026-08-31 +Date: 2026-09-11 Two operations in the API return more than fits in one response, and both use the same opaque -cursor envelope: **listing a session's messages** and **listing sessions**. Everything else returns -its whole answer at once. +cursor envelope: **listing a session's messages** and **listing sessions**. Both can be paged by +hand or walked for you. Everything else returns its whole answer at once. - [🧾 The cursor envelope](#-the-cursor-envelope) - [πŸŽ›οΈ Shaping the request](#️-shaping-the-request) - [πŸ” Paging by hand](#-paging-by-hand) - [♾️ Or let the SDK page for you](#️-or-let-the-sdk-page-for-you) +- [πŸ“„ The same walk, one page at a time](#-the-same-walk-one-page-at-a-time) - [βš–οΈ Which one to use](#️-which-one-to-use) ## 🧾 The cursor envelope @@ -53,13 +54,14 @@ Three channels do the paging, and both request types carry all three: | `Order` | `ListOrder?` | `Ascending` or `Descending`. **First page only.** | | `Cursor` | `string?` | The opaque continuation from the previous page's `Cursor.Next`. | -`MessageListRequest` inherits them from the shared abstract `ListRequest`. -`SessionListRequest` declares the same three itself, next to its own filters β€” `Search`, -`Project`, `Workspace`, `Directory`, `Subpath`, and `ParentId`. +`MessageListRequest` and `SessionListRequest` both inherit all three from the shared abstract +`ListRequest`. A request may also carry filters of its own beside them: `SessionListRequest` adds +`Search`, `Project`, `Workspace`, `Directory`, `Subpath`, and `ParentId`. -> **🧭 `Order` belongs to the first request.** The order is fixed when the sequence starts; a -> continuation carries `Limit` and `Cursor` only. Keeping `Order` on later pages is not something -> the SDK will correct for you β€” the snippets below drop it deliberately. +> **🧭 `Order` belongs to the first request.** The order is fixed when the walk starts; a +> continuation carries `Limit`, `Cursor`, and every filter β€” but not `Order`. Paging by hand, that +> is yours to get right, and the snippets below drop it deliberately. Paging automatically, the SDK +> does it for you. ## πŸ” Paging by hand @@ -110,13 +112,14 @@ while (true) } ``` -Manual paging is what you want when you need the page metadata: the status of each request, -`NoThrow` handling per page, or `Cursor.Previous` to walk backwards. +Manual paging is what you want when the walk itself is yours: per-page `NoThrow` handling, +`Cursor.Previous` to move backwards, or a cursor you stop at and resume from later. ## ♾️ Or let the SDK page for you -`SessionClient.EnumerateMessagesAsync` is the automatic companion to `ListMessagesAsync`. It yields -the **items**, lazily, following `Next` for you: +`SessionClient.EnumerateMessagesAsync` and `SessionsClient.EnumerateSessionsAsync` are the automatic +companions to `ListMessagesAsync` and `ListSessionsAsync`. Each yields the **items**, lazily, +following `Next` for you: ```csharp var stream = session.EnumerateMessagesAsync(new MessageListRequest @@ -131,33 +134,74 @@ await foreach (var message in stream.WithCancellation(cancellationToken)) } ``` -What it does with your request is exactly the manual loop above: the first request goes out -unchanged, every continuation keeps your `Limit`, drops `Order`, and sends the returned cursor -verbatim. It stops when a page comes back without a `Next`. +Sessions enumerate the same way, filters and all: -Three things to know: +```csharp +var stream = client.Sessions.EnumerateSessionsAsync(new SessionListRequest +{ + Limit = "25", + Order = ListOrder.Descending, + Search = "build", +}); + +await foreach (var session in stream.WithCancellation(cancellationToken)) +{ + Console.WriteLine($"{session.Id} {session.Title}"); +} +``` + +What it does with your request is the manual loop above, with one thing done better: the first +request goes out unchanged, and **every continuation is your request again** β€” same `Limit`, same +`Search`, `Project`, `ParentId`, and every other filter β€” with `Order` dropped and the returned +cursor put in place. It stops when a page comes back without a `Next`. + +Four things to know: - **It is a pull sequence over ordinary HTTP calls**, not a stream. Nothing is held open between pages, and stopping early (a `break`, a `return`, a cancelled token) simply means the next page is never requested. -- **It always throws.** There is no envelope to hand you, so an API error on page seven throws +- **It always throws.** No envelope reaches the item loop, so an API error on page seven throws `OpenCodeApiException` out of the `await foreach` β€” `NoThrow` is not available here. - **Cancellation reaches every request** and is also checked between buffered items, so a token you - cancel mid-page takes effect immediately rather than after the current page drains. + cancel mid-page takes effect immediately rather than after the current page drains. The token you + hand to `Enumerate*Async` and the one you hand to `WithCancellation` are both observed. +- **Each enumeration is its own walk.** Keeping the returned sequence in a variable and enumerating + it twice sends every request twice: it is a recipe, not a buffer. -`EnumerateMessagesAsync` is the **only** automatic companion in the SDK today. Session listing has -no `EnumerateSessionsAsync`; page it with the loop above. +## πŸ“„ The same walk, one page at a time + +`Enumerate*Async` returns a `CursorSequence`. Enumerating it gives you the items; its +`Pages` property gives you the same walk as **page envelopes** β€” the very `MessageListResponse` or +`SessionListResponse` the one-page call would have handed you, `Status`, `Cursor`, `RawBody` and +all: + +```csharp +await foreach (var page in session.EnumerateMessagesAsync(request) + .Pages + .WithCancellation(cancellationToken)) +{ + Console.WriteLine($"{page.Messages.Count} messages, next {page.Cursor.Next ?? ""}"); +} +``` + +Reach for it when the cursor bookkeeping should be handled but the page is still part of what you +are doing β€” reporting progress per request, checkpointing `Cursor.Next`, or reading a page's status. +There is no page-size contract here: a page is exactly what the server returned for one request. + +The two doors are independent walks of the same recipe, so enumerating the items and then the pages +performs both walks β€” two full sets of requests. And `NoThrow` remains unavailable: an error page +throws when the walk reaches it, exactly as it does for items. ## βš–οΈ Which one to use -| | `List*Async` | `EnumerateMessagesAsync` | -|---|---|---| -| You get | one page envelope | the items, across all pages | -| Page metadata (`Status`, `Cursor`) | βœ… yes | ❌ no | -| Per-call `NoThrow` | βœ… yes | ❌ no β€” always throws | -| Backwards paging via `Previous` | βœ… yes | ❌ no | -| Cursor bookkeeping | yours | handled | - -Reach for `EnumerateMessagesAsync` when you just want the messages. Reach for `ListMessagesAsync` -when the page itself is part of what you are doing β€” rendering a paged UI, checkpointing a cursor, -or treating a failed page as data. +| | `List*Async` | `Enumerate*Async` | `Enumerate*Async().Pages` | +|---|---|---|---| +| You get | one page envelope | the items, across all pages | every page envelope | +| Page metadata (`Status`, `Cursor`) | βœ… yes | ❌ no | βœ… yes | +| Per-call `NoThrow` | βœ… yes | ❌ no β€” always throws | ❌ no β€” always throws | +| Backwards paging via `Previous` | βœ… yes | ❌ no | ❌ no | +| Cursor bookkeeping | yours | handled | handled | + +Reach for `Enumerate*Async` when you just want the messages or the sessions, and for its `Pages` +when you want the walk handled but the envelope kept. Reach for `List*Async` when you own the walk: +a paged UI, `Cursor.Previous`, or treating a failed page as data. diff --git a/external/opencode b/external/opencode index f9bc2233d..20aff6d9f 160000 --- a/external/opencode +++ b/external/opencode @@ -1 +1 @@ -Subproject commit f9bc2233ddcc11503a0804b317401bc221ef0e7f +Subproject commit 20aff6d9f643afe9abf8a048e68f019d049f5329 diff --git a/spec/SNAPSHOT.md b/spec/SNAPSHOT.md index e2fe0fd86..7680a7952 100644 --- a/spec/SNAPSHOT.md +++ b/spec/SNAPSHOT.md @@ -1,6 +1,6 @@ # OpenAPI Snapshot -Date: 2026-09-08 +Date: 2026-09-11 `openapi.json` is the accepted snapshot of the upstream opencode OpenAPI 3.1 document β€” the v2 protocol surface (ADR-0005). The SDK is built against this snapshot, never against a live @@ -14,13 +14,17 @@ their hash-pinned manifests. |---|---| | Upstream file | `packages/protocol/openapi.json` | | Upstream branch | `v2` (active successor line; no release tags yet) | -| Commit | `f9bc2233ddcc11503a0804b317401bc221ef0e7f` | -| Upstream product channel | `opencode2` β€” npm `@opencode-ai/cli@next` (pre-release) | - -At this pin the CLI package is named `@opencode/cli`, following upstream's rename of its workspace -scope from `@opencode-ai`. The published channel has not moved with it: npm's `@opencode` scope -carries a reserved placeholder on `latest`, so `@opencode-ai/cli@next` stays the installable -pre-release and is what the row above names. +| Commit | `20aff6d9f643afe9abf8a048e68f019d049f5329` | +| Upstream product channel | `opencode2` β€” npm `@opencode/cli@beta` (pre-release); this commit shipped as `0.0.0-beta-19425` | + +Upstream publishes the v2 line from the `@opencode/cli` npm scope: every push to `v2` becomes a +`dev` build and every promotion of the `beta` branch becomes a `beta` build, versioned +`0.0.0--`. This pin is the `beta` branch head that shipped as +`@opencode/cli@0.0.0-beta-19425`, so `npm install -g @opencode/cli@0.0.0-beta-19425` installs a +server built from exactly this commit; later `beta` builds usually work but are not what this +repository tests. The former `@opencode-ai/cli` scope is frozen: its `next` and `latest` tags +stopped at `0.0.0-beta-17823` (published 2026-08-21) and the scope received no publish after +2026-09-07, so that install line no longer reaches a server this SDK can drive. `openapi.json` is derived from upstream's own generator output, so upstream's MIT notice travels with it in [`THIRD-PARTY-NOTICES.md`](../THIRD-PARTY-NOTICES.md). diff --git a/spec/openapi.json b/spec/openapi.json index 45a37daf7..105ef6c33 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -4278,143 +4278,6 @@ }, "description": "Retrieve one projected message owned by the Session.", "summary": "Get session message" - }, - "patch": { - "tags": ["session"], - "operationId": "v2.session.messageUpdate", - "parameters": [ - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses" - }, - "required": true - }, - { - "name": "messageID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^msg_" - }, - "required": true - } - ], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "$ref": "#/components/schemas/Session.Message.Assistant" - } - }, - "required": ["data"], - "additionalProperties": false - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/InvalidRequestErrorEncoded" - }, - { - "$ref": "#/components/schemas/InvalidRequestErrorEncoded" - } - ] - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedErrorEncoded" - } - } - } - }, - "404": { - "description": "SessionNotFoundError | MessageNotFoundError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionNotFoundErrorEncoded" - }, - { - "$ref": "#/components/schemas/MessageNotFoundErrorEncoded" - } - ] - } - } - } - }, - "409": { - "description": "SessionBusyError | ConflictError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionBusyErrorEncoded" - }, - { - "$ref": "#/components/schemas/ConflictErrorEncoded" - } - ] - } - } - } - } - }, - "description": "Replace the content of a completed assistant message in an idle session.", - "summary": "Update assistant message content", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Session.Message.Assistant.Text" - }, - { - "$ref": "#/components/schemas/Session.Message.Assistant.Reasoning" - }, - { - "$ref": "#/components/schemas/Session.Message.Assistant.Tool" - } - ] - } - } - }, - "required": ["content"], - "additionalProperties": false - } - } - }, - "required": true - } } }, "/api/session/{sessionID}/environment": { @@ -4627,6 +4490,34 @@ ] }, "required": false + }, + { + "name": "type", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "string", + "enum": [ + "agent-switched", + "model-switched", + "location-switched", + "user", + "synthetic", + "system", + "skill", + "shell", + "assistant", + "compaction" + ] + }, + { + "type": "null" + } + ], + "description": "Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors." + }, + "required": false } ], "security": [], @@ -4689,7 +4580,7 @@ } } }, - "description": "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "description": "Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.", "summary": "Get session messages" } }, @@ -14380,6 +14271,9 @@ "compaction": { "$ref": "#/components/schemas/Provider.Compaction" }, + "websocket": { + "type": "boolean" + }, "modelID": { "type": "string" }, @@ -14488,6 +14382,9 @@ "compaction": { "$ref": "#/components/schemas/Provider.Compaction" }, + "websocket": { + "type": "boolean" + }, "canonical": { "type": "string" }, @@ -16472,6 +16369,9 @@ "compaction": { "$ref": "#/components/schemas/Provider.Compaction" }, + "websocket": { + "type": "boolean" + }, "settings": { "type": "object" }, @@ -17460,6 +17360,9 @@ "compaction": { "$ref": "#/components/schemas/Provider.Compaction" }, + "websocket": { + "type": "boolean" + }, "settings": { "type": "object" }, @@ -17892,10 +17795,10 @@ "$ref": "#/components/schemas/session.revert.committed" }, { - "$ref": "#/components/schemas/session.message.content.updated" + "$ref": "#/components/schemas/session.usage.recorded" }, { - "$ref": "#/components/schemas/session.usage.recorded" + "$ref": "#/components/schemas/session.message.content.updated" } ] }, @@ -18754,6 +18657,12 @@ }, "providerContext": { "$ref": "#/components/schemas/Session.ProviderContext" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": ["type", "id", "time", "status", "reason", "summary", "recent"], @@ -18793,6 +18702,12 @@ }, "error": { "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": ["type", "id", "time", "status", "reason", "error"], @@ -20414,9 +20329,6 @@ { "$ref": "#/components/schemas/session.revert.committed" }, - { - "$ref": "#/components/schemas/session.message.content.updated" - }, { "$ref": "#/components/schemas/filesystem.changed" }, @@ -22026,6 +21938,12 @@ }, "recent": { "type": "string" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": ["sessionID", "reason", "text", "recent"], @@ -22090,6 +22008,12 @@ "inputID": { "type": "string", "pattern": "^msg_" + }, + "cost": { + "$ref": "#/components/schemas/Money.USD" + }, + "tokens": { + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": ["sessionID", "reason", "error"], diff --git a/spec/receipt.json b/spec/receipt.json index e39626593..2d6234561 100644 --- a/spec/receipt.json +++ b/spec/receipt.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "upstreamCommit": "f9bc2233ddcc11503a0804b317401bc221ef0e7f", - "rawDocumentSha256": "0bef23a00ff19c711cd871c7f0f9cc592ec81552aa94344f701ae380d2d752dc", - "generatedBaselineSha256": "180707057921ff2e1103066b0993db3646b19afbddf000ff7dba74c0cd36f179", + "upstreamCommit": "20aff6d9f643afe9abf8a048e68f019d049f5329", + "rawDocumentSha256": "e2fc4e03e93547fd8f6a93a9dafcb497a4f6ae99b02f2eb6d5f3fa6eebac734c", + "generatedBaselineSha256": "e2fc4e03e93547fd8f6a93a9dafcb497a4f6ae99b02f2eb6d5f3fa6eebac734c", "patches": [ { "manifest": "001-restore-sse-payloads.json", @@ -14,17 +14,19 @@ }, { "path": "packages/protocol/src/groups/session.ts", - "sha256": "c4659072876fc3232650439cec9dd57d23b9c0b1666e3f8d1897cf1854b83047" + "sha256": "793e21081e6d9110e267f31549bf011fab68b3914026a546119d849b33528327" } ] } ], - "normalizedDocumentSha256": "809ad52800fa56ad266d2c5283d254c82200c64dae46b19a6edeba6e65bf41e2", + "normalizedDocumentSha256": "4a9513f7bde05861d627dad26ef94b7d8e789cdd7a5317bc471f52b9f8d0e92b", "normalizedDocumentPath": null, - "operationSetDigest": "038fa8d3b8cf12a9a3db9320f5945e5c7aaa5d07158cebc6a0527e4c56a0a2f8", - "operationCount": 140, + "operationSetDigest": "dd1a6ed926715137c7158bca3e7aaf9940aba556bd2d0fa0acad2260c5e8cb6b", + "operationCount": 139, "addedOperations": [], - "removedOperations": [], + "removedOperations": [ + "v2.session.messageUpdate" + ], "componentCount": 355, "contentSchemaCount": 2, "watchedSources": [ @@ -110,7 +112,7 @@ }, { "path": "packages/server/src/process.ts", - "sha256": "fb3bbb748814edfa92901e7228dfae707c3c668c4442e82648bf2fc6eaff8974", + "sha256": "88d89a520b684cba37499ad2f2f3f51f2cb60a4c397c550d09d70735b8514f42", "anchorMatched": true }, { diff --git a/spec/source-watch.json b/spec/source-watch.json index a2753d829..a58ab39e0 100644 --- a/spec/source-watch.json +++ b/spec/source-watch.json @@ -147,7 +147,7 @@ }, { "path": "packages/server/src/process.ts", - "sha256": "fb3bbb748814edfa92901e7228dfae707c3c668c4442e82648bf2fc6eaff8974", + "sha256": "88d89a520b684cba37499ad2f2f3f51f2cb60a4c397c550d09d70735b8514f42", "behavior": "A ticketed PTY connect is admitted before the server reports ready.", "anchor": { "type": "contains", diff --git a/src/OpenCode.Sdk/.generated-manifest.json b/src/OpenCode.Sdk/.generated-manifest.json index e66143442..884fb44c7 100644 --- a/src/OpenCode.Sdk/.generated-manifest.json +++ b/src/OpenCode.Sdk/.generated-manifest.json @@ -142,7 +142,6 @@ "Internal/ResponseAdapters/SessionInterruptPostResponseAdapter.cs", "Internal/ResponseAdapters/SessionListResponseAdapter.cs", "Internal/ResponseAdapters/SessionMessageResponseAdapter.cs", - "Internal/ResponseAdapters/SessionMessageUpdatePatchResponseAdapter.cs", "Internal/ResponseAdapters/SessionMovePostResponseAdapter.cs", "Internal/ResponseAdapters/SessionPermissionCreateResponseAdapter.cs", "Internal/ResponseAdapters/SessionPermissionListResponseAdapter.cs", @@ -264,12 +263,10 @@ "Internal/Serialization/SessionLogItemJsonConverter.cs", "Internal/Serialization/SessionMessageAssistantContentEncodedJsonConverter.cs", "Internal/Serialization/SessionMessageAssistantContentJsonConverter.cs", - "Internal/Serialization/SessionMessageAssistantJsonConverter.cs", "Internal/Serialization/SessionMessageCompactionJsonConverter.cs", "Internal/Serialization/SessionMessageInfoJsonConverter.cs", "Internal/Serialization/SessionMessageResponseEnvelope.cs", "Internal/Serialization/SessionMessageToolStateJsonConverter.cs", - "Internal/Serialization/SessionMessageUpdatePatchResponseEnvelope.cs", "Internal/Serialization/SessionPermissionCreateResponseEnvelope.cs", "Internal/Serialization/SessionPermissionListResponseEnvelope.cs", "Internal/Serialization/SessionPermissionResponseEnvelope.cs", @@ -311,7 +308,6 @@ "Internal/Serialization/UnknownSessionLogItemJsonConverter.cs", "Internal/Serialization/UnknownSessionMessageAssistantContentEncodedJsonConverter.cs", "Internal/Serialization/UnknownSessionMessageAssistantContentJsonConverter.cs", - "Internal/Serialization/UnknownSessionMessageAssistantJsonConverter.cs", "Internal/Serialization/UnknownSessionMessageCompactionJsonConverter.cs", "Internal/Serialization/UnknownSessionMessageInfoJsonConverter.cs", "Internal/Serialization/UnknownSessionMessageToolStateJsonConverter.cs", @@ -429,7 +425,6 @@ "Models/ISessionInboxInfo.cs", "Models/ISessionInboxItem.cs", "Models/ISessionLogItem.cs", - "Models/ISessionMessageAssistant.cs", "Models/ISessionMessageAssistantContent.cs", "Models/ISessionMessageAssistantContentEncoded.cs", "Models/ISessionMessageCompaction.cs", @@ -504,6 +499,7 @@ "Models/McpStatusFailed.cs", "Models/McpStatusNeedsAuth.cs", "Models/McpStatusPending.cs", + "Models/MessageListRequestType.cs", "Models/MessageNotFoundError.cs", "Models/ModelCapabilities.cs", "Models/ModelCompatibility.cs", @@ -740,7 +736,6 @@ "Models/SessionMessageToolStateError.cs", "Models/SessionMessageToolStateRunning.cs", "Models/SessionMessageToolStateStreaming.cs", - "Models/SessionMessageUpdatePatchRequest.cs", "Models/SessionMessageUser.cs", "Models/SessionMessageUserTime.cs", "Models/SessionModelSelected.cs", @@ -924,7 +919,6 @@ "Models/UnknownSessionInboxInfo.cs", "Models/UnknownSessionInboxItem.cs", "Models/UnknownSessionLogItem.cs", - "Models/UnknownSessionMessageAssistant.cs", "Models/UnknownSessionMessageAssistantContent.cs", "Models/UnknownSessionMessageAssistantContentEncoded.cs", "Models/UnknownSessionMessageCompaction.cs", @@ -1053,7 +1047,6 @@ "Sessions/SessionListResponse.cs", "Sessions/SessionLogRequest.cs", "Sessions/SessionMessageResponse.cs", - "Sessions/SessionMessageUpdatePatchResponse.cs", "Sessions/SessionMovePostResponse.cs", "Sessions/SessionPermissionCreateResponse.cs", "Sessions/SessionPermissionListResponse.cs", diff --git a/src/OpenCode.Sdk/.generation-incomplete b/src/OpenCode.Sdk/.generation-incomplete index 87a3d16e1..4d205fab6 100644 --- a/src/OpenCode.Sdk/.generation-incomplete +++ b/src/OpenCode.Sdk/.generation-incomplete @@ -1,5 +1,5 @@ Generation is complete at the declared coverage; packages may be published. -Selected operations: 135 +Selected operations: 134 Pending operations: 0 Declined operations: 3 Transport-owned operations: 2 @@ -99,7 +99,6 @@ Selected: - v2.session.list - v2.session.log - v2.session.message -- v2.session.messageUpdate - v2.session.move - v2.session.permission.create - v2.session.permission.get diff --git a/src/OpenCode.Sdk/Internal/Pagination/CursorPaginator.cs b/src/OpenCode.Sdk/Internal/Pagination/CursorPaginator.cs index ec2c19bd8..f003a7993 100644 --- a/src/OpenCode.Sdk/Internal/Pagination/CursorPaginator.cs +++ b/src/OpenCode.Sdk/Internal/Pagination/CursorPaginator.cs @@ -5,8 +5,8 @@ namespace OpenCode.Sdk.Internal.Pagination; /// Lazily traverses generated cursor-list operations through their ordinary one-page methods. internal static class CursorPaginator { - /// Creates a lazy item sequence over the operation's opaque next cursors. - public static IAsyncEnumerable EnumerateAsync( + /// Creates a lazy sequence over the operation's opaque next cursors. + public static CursorSequence EnumerateAsync( Func> fetchPage, TRequest? initialRequest, ICursorPageAdapter adapter, @@ -17,10 +17,17 @@ public static IAsyncEnumerable EnumerateAsync ArgumentNullException.ThrowIfNull(fetchPage); ArgumentNullException.ThrowIfNull(adapter); - return EnumerateCoreAsync(fetchPage, initialRequest, adapter, cancellationToken); + return new CursorSequence( + token => EnumeratePagesCoreAsync(fetchPage, initialRequest, adapter, token), + adapter.GetItems, + cancellationToken); } - private static async IAsyncEnumerable EnumerateCoreAsync( + /// + /// The single traversal: one page per request, the next request built from the initial one and + /// the opaque returned cursor, and an absent next cursor as the only end signal. + /// + private static async IAsyncEnumerable EnumeratePagesCoreAsync( Func> fetchPage, TRequest? initialRequest, ICursorPageAdapter adapter, @@ -32,11 +39,7 @@ private static async IAsyncEnumerable EnumerateCoreAsync GetItems(MessageListResponse response) } /// - /// Creates a continuation request carrying the initial limit and opaque next cursor. + /// Creates a continuation request carrying every initial filter beside the opaque next cursor, with the first-page-only order dropped. /// public MessageListRequest CreateNextRequest(MessageListRequest? initialRequest, string cursor) { ArgumentNullException.ThrowIfNull(cursor); - return new MessageListRequest + return (initialRequest ?? new()) with { - Limit = initialRequest?.Limit, + Order = null, Cursor = cursor }; } diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs index 82d6d9868..e080728e0 100644 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs +++ b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionListResponseAdapter.cs @@ -1,12 +1,14 @@ // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Internal.Pagination; using OpenCode.Sdk.Internal.Serialization; +using OpenCode.Sdk.Models; namespace OpenCode.Sdk.Internal.ResponseAdapters; /// /// Adapts the 'GET /api/session' responses onto 'SessionListResponse'. /// -internal sealed class SessionListResponseAdapter : ResponseAdapter +internal sealed class SessionListResponseAdapter : ResponseAdapter, ICursorPageAdapter { private static readonly string[] Status400Tags = ["InvalidCursorError", "InvalidRequestError"]; private static readonly string[] Status401Tags = ["UnauthorizedError"]; @@ -50,6 +52,37 @@ public override SessionListResponse Adapt(int status, string rawBody) }; } + /// + /// Gets the ordered items carried by one successful page. + /// + public IReadOnlyList GetItems(SessionListResponse response) + { + ArgumentNullException.ThrowIfNull(response); + return response.Sessions; + } + + /// + /// Gets the opaque next cursor carried by one successful page. + /// + public string? GetNextCursor(SessionListResponse response) + { + ArgumentNullException.ThrowIfNull(response); + return response.Cursor.Next; + } + + /// + /// Creates a continuation request carrying every initial filter beside the opaque next cursor, with the first-page-only order dropped. + /// + public SessionListRequest CreateNextRequest(SessionListRequest? initialRequest, string cursor) + { + ArgumentNullException.ThrowIfNull(cursor); + return (initialRequest ?? new()) with + { + Order = null, + Cursor = cursor + }; + } + private static SessionListResponse CreateSuccess(int status, SessionListResponseEnvelope envelope) => new() { Status = status, diff --git a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageUpdatePatchResponseAdapter.cs b/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageUpdatePatchResponseAdapter.cs deleted file mode 100644 index 15f1ea165..000000000 --- a/src/OpenCode.Sdk/Internal/ResponseAdapters/SessionMessageUpdatePatchResponseAdapter.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Generated by OpenCode.Sdk.Tools from spec/openapi.json. -// Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. -using OpenCode.Sdk.Internal.Serialization; - -namespace OpenCode.Sdk.Internal.ResponseAdapters; -/// -/// Adapts the 'PATCH /api/session/{sessionID}/message/{messageID}' responses onto 'SessionMessageUpdatePatchResponse'. -/// -internal sealed class SessionMessageUpdatePatchResponseAdapter : ResponseAdapter -{ - private static readonly string[] Status400Tags = ["InvalidRequestError"]; - private static readonly string[] Status401Tags = ["UnauthorizedError"]; - private static readonly string[] Status404Tags = ["MessageNotFoundError", "SessionNotFoundError"]; - private static readonly string[] Status409Tags = ["ConflictError", "SessionBusyError"]; - private SessionMessageUpdatePatchResponseAdapter() - { - } - - /// - /// Gets the shared adapter instance. - /// - public static SessionMessageUpdatePatchResponseAdapter Instance { get; } = new SessionMessageUpdatePatchResponseAdapter(); - - /// - /// Classifies a status under this operation's pinned contract. - /// - public override StatusVerdict Classify(int status) => status switch - { - 200 => StatusVerdict.Success, - >= 200 and < 300 => StatusVerdict.UndeclaredSuccess, - 400 => StatusVerdict.DeclaredError, - 401 => StatusVerdict.DeclaredError, - 404 => StatusVerdict.DeclaredError, - 409 => StatusVerdict.DeclaredError, - _ => StatusVerdict.UndeclaredError - }; - /// - /// Maps the declared UTF-8 success body onto the typed envelope. - /// - public override SessionMessageUpdatePatchResponse AdaptSuccess(int status, ReadOnlySpan utf8Body) => new() - { - Status = status, - MessageUpdate = ReadBarePayload(utf8Body, OpenCodeJsonContext.Default.SessionMessageUpdatePatchResponseEnvelope).Data - }; - /// - /// Maps one buffered response onto the typed envelope. - /// - public override SessionMessageUpdatePatchResponse Adapt(int status, string rawBody) - { - ArgumentNullException.ThrowIfNull(rawBody); - return status switch - { - 200 => new SessionMessageUpdatePatchResponse - { - Status = status, - MessageUpdate = ReadBarePayload(rawBody, OpenCodeJsonContext.Default.SessionMessageUpdatePatchResponseEnvelope).Data - }, - >= 200 and < 300 => throw StatusVerdictFailures.UndeclaredSuccess(status), - 400 => new SessionMessageUpdatePatchResponse(status, ReadTolerantError(rawBody, Status400Tags), rawBody), - 401 => new SessionMessageUpdatePatchResponse(status, ReadTolerantError(rawBody, Status401Tags), rawBody), - 404 => new SessionMessageUpdatePatchResponse(status, ReadTolerantError(rawBody, Status404Tags), rawBody), - 409 => new SessionMessageUpdatePatchResponse(status, ReadTolerantError(rawBody, Status409Tags), rawBody), - _ => new SessionMessageUpdatePatchResponse(status, ReadTolerantError(rawBody, null), rawBody) - }; - } -} diff --git a/src/OpenCode.Sdk/Internal/Serialization/EventJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/EventJsonConverter.cs index 930a77551..ad67e065e 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/EventJsonConverter.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/EventJsonConverter.cs @@ -57,7 +57,6 @@ internal sealed class EventJsonConverter : JsonConverter ["session.inbox.delivery.changed"] = typeof(SessionInboxDeliveryChanged), ["session.inbox.enqueued"] = typeof(SessionInboxEnqueued), ["session.instructions.updated"] = typeof(SessionInstructionsUpdated), - ["session.message.content.updated"] = typeof(SessionMessageContentUpdated), ["session.model.selected"] = typeof(SessionModelSelected), ["session.moved"] = typeof(SessionMoved), ["session.reasoning.delta"] = typeof(SessionReasoningDelta), diff --git a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs index 04a952df4..3e2621321 100644 --- a/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs +++ b/src/OpenCode.Sdk/Internal/Serialization/OpenCodeJsonContext.cs @@ -92,7 +92,6 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(ISessionInboxInfo))] [JsonSerializable(typeof(ISessionInboxItem))] [JsonSerializable(typeof(ISessionLogItem))] -[JsonSerializable(typeof(ISessionMessageAssistant))] [JsonSerializable(typeof(ISessionMessageAssistantContent))] [JsonSerializable(typeof(ISessionMessageAssistantContentEncoded))] [JsonSerializable(typeof(ISessionMessageCompaction))] @@ -175,6 +174,7 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(McpStatusFailed))] [JsonSerializable(typeof(McpStatusNeedsAuth))] [JsonSerializable(typeof(McpStatusPending))] +[JsonSerializable(typeof(MessageListRequestType))] [JsonSerializable(typeof(MessageListResponseEnvelope))] [JsonSerializable(typeof(MessageNotFoundError))] [JsonSerializable(typeof(ModelCapabilities))] @@ -451,8 +451,6 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(SessionMessageToolStateError))] [JsonSerializable(typeof(SessionMessageToolStateRunning))] [JsonSerializable(typeof(SessionMessageToolStateStreaming))] -[JsonSerializable(typeof(SessionMessageUpdatePatchRequest))] -[JsonSerializable(typeof(SessionMessageUpdatePatchResponseEnvelope))] [JsonSerializable(typeof(SessionMessageUser))] [JsonSerializable(typeof(SessionMessageUserTime))] [JsonSerializable(typeof(SessionModelSelected))] @@ -650,7 +648,6 @@ namespace OpenCode.Sdk.Internal.Serialization; [JsonSerializable(typeof(UnknownSessionInboxInfo))] [JsonSerializable(typeof(UnknownSessionInboxItem))] [JsonSerializable(typeof(UnknownSessionLogItem))] -[JsonSerializable(typeof(UnknownSessionMessageAssistant))] [JsonSerializable(typeof(UnknownSessionMessageAssistantContent))] [JsonSerializable(typeof(UnknownSessionMessageAssistantContentEncoded))] [JsonSerializable(typeof(UnknownSessionMessageCompaction))] diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantJsonConverter.cs deleted file mode 100644 index 046708420..000000000 --- a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageAssistantJsonConverter.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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.Frozen; -using System.Text.Json; -using System.Text.Json.Serialization; -using OpenCode.Sdk.Models; - -namespace OpenCode.Sdk.Internal.Serialization; - -internal sealed class SessionMessageAssistantJsonConverter : JsonConverter -{ - private static readonly FrozenDictionary TypesByTag = new Dictionary(StringComparer.Ordinal) - { - ["reasoning"] = typeof(SessionMessageAssistantReasoning), - ["text"] = typeof(SessionMessageAssistantText), - ["tool"] = typeof(SessionMessageAssistantTool) - }.ToFrozenDictionary(StringComparer.Ordinal); - private static readonly UnionDiscriminatorReader DiscriminatorReader = new(); - public override ISessionMessageAssistant Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - ArgumentNullException.ThrowIfNull(typeToConvert); - ArgumentNullException.ThrowIfNull(options); - if (DiscriminatorReader.TryFindKnown(ref reader, "type", "SessionMessageAssistant", TypesByTag, out var targetType, out var marker)) - { - var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(targetType) ?? throw new JsonException("The generated context has no metadata for SessionMessageAssistant."); - return JsonSerializer.Deserialize(ref reader, typeInfo) as ISessionMessageAssistant ?? throw new JsonException("The SessionMessageAssistant payload deserialized to null."); - } - - using var document = JsonDocument.ParseValue(ref reader); - var payload = document.RootElement; - return new UnknownSessionMessageAssistant(marker, payload); - } - - public override void Write(Utf8JsonWriter writer, ISessionMessageAssistant value, JsonSerializerOptions options) - { - ArgumentNullException.ThrowIfNull(writer); - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(options); - if (value is UnknownSessionMessageAssistant unknown) - { - unknown.Payload.WriteTo(writer); - return; - } - - var typeInfo = OpenCodeJsonContext.Default.GetTypeInfo(value.GetType()) ?? throw new JsonException("The generated context has no metadata for SessionMessageAssistant."); - JsonSerializer.Serialize(writer, value, typeInfo); - } -} diff --git a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageUpdatePatchResponseEnvelope.cs b/src/OpenCode.Sdk/Internal/Serialization/SessionMessageUpdatePatchResponseEnvelope.cs deleted file mode 100644 index 173d088ce..000000000 --- a/src/OpenCode.Sdk/Internal/Serialization/SessionMessageUpdatePatchResponseEnvelope.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Generated by OpenCode.Sdk.Tools from spec/openapi.json. -// Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. -using System.Text.Json.Serialization; -using OpenCode.Sdk.Models; - -namespace OpenCode.Sdk.Internal.Serialization; -/// -/// Carries the wire body of 'SessionMessageUpdatePatchResponse' through one deserialization pass. -/// -internal sealed record SessionMessageUpdatePatchResponseEnvelope -{ - [JsonPropertyName("data")] - public required SessionMessageAssistant Data { get; init; } -} diff --git a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantJsonConverter.cs b/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantJsonConverter.cs deleted file mode 100644 index 0dd0f22f5..000000000 --- a/src/OpenCode.Sdk/Internal/Serialization/UnknownSessionMessageAssistantJsonConverter.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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 UnknownSessionMessageAssistantJsonConverter : JsonConverter -{ - public override UnknownSessionMessageAssistant Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - ArgumentNullException.ThrowIfNull(typeToConvert); - ArgumentNullException.ThrowIfNull(options); - using var document = JsonDocument.ParseValue(ref reader); - var payload = document.RootElement; - if (payload.ValueKind != JsonValueKind.Object) - { - throw new JsonException("The SessionMessageAssistant payload must be a JSON object."); - } - - if (!payload.TryGetProperty("type", out var markerElement)) - { - throw new JsonException("The SessionMessageAssistant payload must contain 'type'."); - } - - if (markerElement.ValueKind != JsonValueKind.String) - { - throw new JsonException("The 'type' marker must be a string."); - } - - var marker = markerElement.GetString(); - if (marker is null || string.IsNullOrWhiteSpace(marker)) - { - throw new JsonException("The 'type' marker must be a non-empty string."); - } - - return new UnknownSessionMessageAssistant(marker, payload); - } - - public override void Write(Utf8JsonWriter writer, UnknownSessionMessageAssistant value, JsonSerializerOptions options) - { - ArgumentNullException.ThrowIfNull(writer); - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(options); - value.Payload.WriteTo(writer); - } -} diff --git a/src/OpenCode.Sdk/Models/ISessionMessageAssistant.cs b/src/OpenCode.Sdk/Models/ISessionMessageAssistant.cs deleted file mode 100644 index 3ac83459a..000000000 --- a/src/OpenCode.Sdk/Models/ISessionMessageAssistant.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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 union. -/// -[JsonConverter(typeof(SessionMessageAssistantJsonConverter))] -public interface ISessionMessageAssistant -{ - /// - /// Gets the 'type' union marker. - /// - [JsonPropertyName("type")] - public string Type { get; } -} diff --git a/src/OpenCode.Sdk/Models/MessageListRequestType.cs b/src/OpenCode.Sdk/Models/MessageListRequestType.cs new file mode 100644 index 000000000..c5b23f430 --- /dev/null +++ b/src/OpenCode.Sdk/Models/MessageListRequestType.cs @@ -0,0 +1,63 @@ +// Generated by OpenCode.Sdk.Tools from spec/openapi.json. +// Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. +using System.Text.Json.Serialization; +using OpenCode.Sdk.Internal.Serialization; + +namespace OpenCode.Sdk.Models; +/// +/// Defines the supported message list request type values. +/// +[JsonConverter(typeof(StrictJsonStringEnumConverter))] +public enum MessageListRequestType +{ + /// + /// Represents the 'agent-switched' wire value. + /// + [JsonStringEnumMemberName("agent-switched")] + AgentSwitched, + /// + /// Represents the 'model-switched' wire value. + /// + [JsonStringEnumMemberName("model-switched")] + ModelSwitched, + /// + /// Represents the 'location-switched' wire value. + /// + [JsonStringEnumMemberName("location-switched")] + LocationSwitched, + /// + /// Represents the 'user' wire value. + /// + [JsonStringEnumMemberName("user")] + User, + /// + /// Represents the 'synthetic' wire value. + /// + [JsonStringEnumMemberName("synthetic")] + Synthetic, + /// + /// Represents the 'system' wire value. + /// + [JsonStringEnumMemberName("system")] + System, + /// + /// Represents the 'skill' wire value. + /// + [JsonStringEnumMemberName("skill")] + Skill, + /// + /// Represents the 'shell' wire value. + /// + [JsonStringEnumMemberName("shell")] + Shell, + /// + /// Represents the 'assistant' wire value. + /// + [JsonStringEnumMemberName("assistant")] + Assistant, + /// + /// Represents the 'compaction' wire value. + /// + [JsonStringEnumMemberName("compaction")] + Compaction +} diff --git a/src/OpenCode.Sdk/Models/ModelInfo.cs b/src/OpenCode.Sdk/Models/ModelInfo.cs index 931b047e3..fb9836b79 100644 --- a/src/OpenCode.Sdk/Models/ModelInfo.cs +++ b/src/OpenCode.Sdk/Models/ModelInfo.cs @@ -68,6 +68,13 @@ public sealed record ModelInfo [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IProviderCompaction? Compaction { get; init; } + /// + /// Gets the websocket value. + /// + [JsonPropertyName("websocket")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Websocket { get; init; } + /// /// Gets the settings value. /// diff --git a/src/OpenCode.Sdk/Models/ProviderInfo.cs b/src/OpenCode.Sdk/Models/ProviderInfo.cs index c93acef42..dc31ca5ab 100644 --- a/src/OpenCode.Sdk/Models/ProviderInfo.cs +++ b/src/OpenCode.Sdk/Models/ProviderInfo.cs @@ -54,6 +54,13 @@ public sealed record ProviderInfo [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IProviderCompaction? Compaction { get; init; } + /// + /// Gets the websocket value. + /// + [JsonPropertyName("websocket")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Websocket { get; init; } + /// /// Gets the settings value. /// diff --git a/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs b/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs index 7b54b26d1..6e1f3d614 100644 --- a/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs +++ b/src/OpenCode.Sdk/Models/SessionCompactionEndedData.cs @@ -53,4 +53,18 @@ public sealed record SessionCompactionEndedData /// [JsonPropertyName("recent")] public required string Recent { 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 TokenUsageInfo? Tokens { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionCompactionFailedData.cs b/src/OpenCode.Sdk/Models/SessionCompactionFailedData.cs index 72c6d1898..e283127be 100644 --- a/src/OpenCode.Sdk/Models/SessionCompactionFailedData.cs +++ b/src/OpenCode.Sdk/Models/SessionCompactionFailedData.cs @@ -32,4 +32,18 @@ public sealed record SessionCompactionFailedData [JsonPropertyName("inputID")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? InputId { 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 TokenUsageInfo? Tokens { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs index b398f89d9..cb0928ac5 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantReasoning.cs @@ -7,7 +7,7 @@ namespace OpenCode.Sdk.Models; /// /// Represents a session message assistant reasoning value. /// -public sealed record SessionMessageAssistantReasoning : ISessionMessageAssistantContent, ISessionMessageAssistantContentEncoded, ISessionMessageAssistant +public sealed record SessionMessageAssistantReasoning : ISessionMessageAssistantContent, ISessionMessageAssistantContentEncoded { /// /// Gets the type value. diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs index ed3b646bf..49fba3548 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantText.cs @@ -7,7 +7,7 @@ namespace OpenCode.Sdk.Models; /// /// Represents a session message assistant text value. /// -public sealed record SessionMessageAssistantText : ISessionMessageAssistantContent, ISessionMessageAssistantContentEncoded, ISessionMessageAssistant +public sealed record SessionMessageAssistantText : ISessionMessageAssistantContent, ISessionMessageAssistantContentEncoded { /// /// Gets the type value. diff --git a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs index 64e7c8e6f..f70b6fbc8 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageAssistantTool.cs @@ -7,7 +7,7 @@ namespace OpenCode.Sdk.Models; /// /// Represents a session message assistant tool value. /// -public sealed record SessionMessageAssistantTool : ISessionMessageAssistantContent, ISessionMessageAssistantContentEncoded, ISessionMessageAssistant +public sealed record SessionMessageAssistantTool : ISessionMessageAssistantContent, ISessionMessageAssistantContentEncoded { /// /// Gets the type value. diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs index 4e236deb1..2c6f13fe3 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionCompleted.cs @@ -78,4 +78,18 @@ public sealed record SessionMessageCompactionCompleted : ISessionMessageCompacti [JsonPropertyName("providerContext")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public SessionProviderContext? ProviderContext { 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 TokenUsageInfo? Tokens { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageCompactionFailed.cs b/src/OpenCode.Sdk/Models/SessionMessageCompactionFailed.cs index a59a93e57..892f39bd0 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageCompactionFailed.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageCompactionFailed.cs @@ -51,4 +51,18 @@ public sealed record SessionMessageCompactionFailed : ISessionMessageCompaction /// [JsonPropertyName("error")] public required SessionStructuredError Error { 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 TokenUsageInfo? Tokens { get; init; } } diff --git a/src/OpenCode.Sdk/Models/SessionMessageContentUpdated.cs b/src/OpenCode.Sdk/Models/SessionMessageContentUpdated.cs index fdfc0477e..7f9953fc6 100644 --- a/src/OpenCode.Sdk/Models/SessionMessageContentUpdated.cs +++ b/src/OpenCode.Sdk/Models/SessionMessageContentUpdated.cs @@ -7,7 +7,7 @@ namespace OpenCode.Sdk.Models; /// /// Represents a session message content updated value. /// -public sealed record SessionMessageContentUpdated : ISessionEventDurable, IEvent +public sealed record SessionMessageContentUpdated : ISessionEventDurable { /// /// Gets the id value. diff --git a/src/OpenCode.Sdk/Models/SessionMessageUpdatePatchRequest.cs b/src/OpenCode.Sdk/Models/SessionMessageUpdatePatchRequest.cs deleted file mode 100644 index 62f2d1fc9..000000000 --- a/src/OpenCode.Sdk/Models/SessionMessageUpdatePatchRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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 update patch request value. -/// -public sealed record SessionMessageUpdatePatchRequest -{ - /// - /// Gets the content value. - /// - [JsonPropertyName("content")] - public required IReadOnlyList Content { get; init; } -} diff --git a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistant.cs b/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistant.cs deleted file mode 100644 index f3d5c228e..000000000 --- a/src/OpenCode.Sdk/Models/UnknownSessionMessageAssistant.cs +++ /dev/null @@ -1,41 +0,0 @@ -// 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.Internal.Serialization; - -namespace OpenCode.Sdk.Models; -/// -/// Preserves an unknown session message assistant payload. -/// -[JsonConverter(typeof(UnknownSessionMessageAssistantJsonConverter))] -public sealed record UnknownSessionMessageAssistant : ISessionMessageAssistant -{ - private readonly string _marker; - /// - /// Initializes an unknown union value from its marker and raw payload. - /// - [JsonConstructor] - public UnknownSessionMessageAssistant(string type, JsonElement payload) - { - ArgumentException.ThrowIfNullOrWhiteSpace(type); - if (payload.ValueKind is JsonValueKind.Undefined) - { - throw new ArgumentException("The payload must be a parsed JSON element.", nameof(payload)); - } - - UnionPayloadGuard.Instance.RequireString(payload, "type", type); - _marker = type; - Payload = payload.Clone(); - } - - /// - /// Gets the unrecognized 'type' marker. - /// - [JsonPropertyName("type")] - public string Type => _marker; - /// - /// Gets the preserved raw JSON payload. - /// - public JsonElement Payload { get; } -} diff --git a/src/OpenCode.Sdk/OpenCodeRoutes.cs b/src/OpenCode.Sdk/OpenCodeRoutes.cs index 6197525c5..3529add0a 100644 --- a/src/OpenCode.Sdk/OpenCodeRoutes.cs +++ b/src/OpenCode.Sdk/OpenCodeRoutes.cs @@ -2031,6 +2031,7 @@ public static string ListMessages(string sessionId, MessageListRequest? request query.AddText("limit", request.Limit); query.AddOrder("order", request.Order); query.AddText("cursor", request.Cursor); + query.AddText("type", ToWireValue(request.Type)); return path + query.Value; } @@ -2084,33 +2085,6 @@ public static string ListSessions(SessionListRequest? request = null) return path + query.Value; } - /// - /// The 'PATCH /api/session/{sessionID}/message/{messageID}' route template. - /// - public const string PatchMessageUpdateTemplate = "/api/session/{sessionID}/message/{messageID}"; - /// - /// Builds the '/api/session/{sessionID}/message/{messageID}' route. - /// - /// The 'sessionID' route value. - /// The 'messageID' route value. - /// The escaped route. - public static string PatchMessageUpdate(string sessionId, string messageId) - { - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - if (sessionId is "." or "..") - { - throw new ArgumentException("Route values must not be dot segments.", nameof(sessionId)); - } - - ArgumentException.ThrowIfNullOrWhiteSpace(messageId); - if (messageId is "." or "..") - { - throw new ArgumentException("Route values must not be dot segments.", nameof(messageId)); - } - - return "/api/session/" + RouteValuePolicy.Escape(sessionId, nameof(sessionId)) + "/message/" + RouteValuePolicy.Escape(messageId, nameof(messageId)); - } - /// /// The 'POST /api/session/{sessionID}/background' route template. /// @@ -3232,6 +3206,24 @@ public static string RemoveWorktree(WorktreeRemoveRequest? request = null) _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown FsFindRequestType value.") }; /// + /// Spells one 'MessageListRequestType' member the way the wire query expects it. + /// + private static string? ToWireValue(MessageListRequestType? value) => value switch + { + MessageListRequestType.AgentSwitched => "agent-switched", + MessageListRequestType.ModelSwitched => "model-switched", + MessageListRequestType.LocationSwitched => "location-switched", + MessageListRequestType.User => "user", + MessageListRequestType.Synthetic => "synthetic", + MessageListRequestType.System => "system", + MessageListRequestType.Skill => "skill", + MessageListRequestType.Shell => "shell", + MessageListRequestType.Assistant => "assistant", + MessageListRequestType.Compaction => "compaction", + null => null, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown MessageListRequestType value.") + }; + /// /// Spells one 'SessionStatsRequestTools' member the way the wire query expects it. /// private static string? ToWireValue(SessionStatsRequestTools? value) => value switch diff --git a/src/OpenCode.Sdk/Pagination/CursorSequence.cs b/src/OpenCode.Sdk/Pagination/CursorSequence.cs new file mode 100644 index 000000000..b6ae47d0e --- /dev/null +++ b/src/OpenCode.Sdk/Pagination/CursorSequence.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; + +namespace OpenCode.Sdk; + +/// +/// The lazy cursor traversal a generated Enumerate*Async companion returns. Enumerating the +/// sequence itself yields the items of every page in order; yields the same +/// traversal's page envelopes. Both doors drive the same traversal independently, so each +/// enumeration issues its own requests, and an API error surfaces when its page is reached. +/// +/// The generated response envelope one page request returns. +/// The item type the pages carry. +public sealed class CursorSequence : IAsyncEnumerable + where TPage : OpenCodeResponse +{ + private readonly Func> _pages; + private readonly Func> _items; + private readonly CancellationToken _sequenceToken; + + internal CursorSequence( + Func> pages, + Func> items, + CancellationToken sequenceToken) + { + ArgumentNullException.ThrowIfNull(pages); + ArgumentNullException.ThrowIfNull(items); + + _pages = pages; + _items = items; + _sequenceToken = sequenceToken; + Pages = new PageSequence(this); + } + + /// + /// Gets the traversal as page envelopes: every response the traversal requests, including its + /// status, cursor, and the rest of the envelope. Enumerating it walks the pages again from the + /// first request. + /// + public IAsyncEnumerable Pages { get; } + + /// + /// Gets an enumerator over the items of every page, in page order. The token supplied here and + /// the token supplied to the companion are both observed. + /// + /// The cancellation token. + /// The item enumerator. + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + EnumerateItemsAsync(_sequenceToken).GetAsyncEnumerator(cancellationToken); + + private async IAsyncEnumerable EnumerateItemsAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var page in _pages(cancellationToken).ConfigureAwait(false)) + { + foreach (var item in _items(page)) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + } + } + } + + /// + /// The page door. It owns no traversal of its own: every enumeration asks the sequence for a + /// fresh page walk, which is what makes the two doors independent. + /// + private sealed class PageSequence(CursorSequence sequence) : IAsyncEnumerable + { + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + sequence._pages(sequence._sequenceToken).GetAsyncEnumerator(cancellationToken); + } +} diff --git a/src/OpenCode.Sdk/Sessions/MessageListRequest.cs b/src/OpenCode.Sdk/Sessions/MessageListRequest.cs index ceddefcb4..05ff89238 100644 --- a/src/OpenCode.Sdk/Sessions/MessageListRequest.cs +++ b/src/OpenCode.Sdk/Sessions/MessageListRequest.cs @@ -1,9 +1,15 @@ // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. +using OpenCode.Sdk.Models; + namespace OpenCode.Sdk; /// /// Shapes the 'GET /api/session/{sessionID}/message' query. 'limit': Maximum number of messages to return. When omitted, the endpoint returns its default page size. 'order': Message order for the first page. Use desc for newest first or asc for oldest first. 'cursor': Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. /// public sealed record MessageListRequest : ListRequest { + /// + /// Filter by message type before pagination. When omitted, all message types are returned. Pass the same type when following cursors. + /// + public MessageListRequestType? Type { get; init; } } diff --git a/src/OpenCode.Sdk/Sessions/SessionClient.cs b/src/OpenCode.Sdk/Sessions/SessionClient.cs index 0d03c17f3..f6f297174 100644 --- a/src/OpenCode.Sdk/Sessions/SessionClient.cs +++ b/src/OpenCode.Sdk/Sessions/SessionClient.cs @@ -238,7 +238,7 @@ public virtual Task ListInstructionsEntryA } /// - /// Get session messages. Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + /// Get session messages. Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page. /// /// The request shaping the query. /// The per-call options. @@ -252,14 +252,14 @@ public virtual Task ListMessagesAsync(MessageListRequest? r } /// - /// Enumerates the items returned by 'GET /api/session/{sessionID}/message' by following each opaque next cursor. + /// Enumerates the items returned by 'GET /api/session/{sessionID}/message' by following each opaque next cursor; every filter of the first request rides each continuation. /// /// The request shaping the first page. /// The cancellation token. - /// The 'ISessionMessageInfo' sequence. + /// The 'ISessionMessageInfo' sequence, whose Pages expose each 'MessageListResponse'. /// The API returned a declared error status (declared: 400, 401, 404, 500); pagination API errors always throw. /// The server could not be reached or returned a malformed success body. - public virtual IAsyncEnumerable EnumerateMessagesAsync(MessageListRequest? request = null, CancellationToken cancellationToken = default) + public virtual CursorSequence EnumerateMessagesAsync(MessageListRequest? request = null, CancellationToken cancellationToken = default) { return CursorPaginator.EnumerateAsync(ListMessagesAsync, request, MessageListResponseAdapter.Instance, cancellationToken); } @@ -277,23 +277,6 @@ public virtual Task ListRequestsAsync(OpenCodeReq return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListRequests(SessionId), SessionPermissionListResponseAdapter.Instance, requestOptions, cancellationToken); } - /// - /// Update assistant message content. Replace the content of a completed assistant message in an idle session. - /// - /// The 'messageID' route value. - /// The request body. - /// The per-call options. - /// The cancellation token. - /// The 'SessionMessageUpdatePatchResponse' envelope. - /// The API returned an error status (declared: 400, 401, 404, 409) and NoThrow was not selected. - /// The server could not be reached or returned a malformed success body. - public virtual Task PatchMessageUpdateAsync(string messageId, SessionMessageUpdatePatchRequest request, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(messageId); - ArgumentNullException.ThrowIfNull(request); - return Pipeline.ExecuteAsync(OpenCodeHttpMethod.Patch, OpenCodeRoutes.Sessions.PatchMessageUpdate(SessionId, messageId), request, OpenCodeJsonContext.Default.SessionMessageUpdatePatchRequest, SessionMessageUpdatePatchResponseAdapter.Instance, requestOptions, cancellationToken); - } - /// /// Background blocking session tools. Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op. /// diff --git a/src/OpenCode.Sdk/Sessions/SessionListRequest.cs b/src/OpenCode.Sdk/Sessions/SessionListRequest.cs index 8e9481575..53c9462f0 100644 --- a/src/OpenCode.Sdk/Sessions/SessionListRequest.cs +++ b/src/OpenCode.Sdk/Sessions/SessionListRequest.cs @@ -2,23 +2,15 @@ // Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. namespace OpenCode.Sdk; /// -/// Shapes the 'GET /api/session' query. +/// Shapes the 'GET /api/session' query. 'limit': Maximum number of sessions to return. Defaults to the newest 50 sessions. 'order': Session order for the first page. Use desc for newest first or asc for oldest first. 'cursor': Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. /// -public sealed record SessionListRequest +public sealed record SessionListRequest : ListRequest { /// /// Gets the 'workspace' query value; the server default applies when unset. /// public string? Workspace { get; init; } /// - /// Maximum number of sessions to return. Defaults to the newest 50 sessions. - /// - public string? Limit { get; init; } - /// - /// Session order for the first page. Use desc for newest first or asc for oldest first. - /// - public ListOrder? Order { get; init; } - /// /// Gets the 'search' query value; the server default applies when unset. /// public string? Search { get; init; } @@ -38,8 +30,4 @@ public sealed record SessionListRequest /// Gets the 'subpath' query value; the server default applies when unset. /// public string? Subpath { get; init; } - /// - /// Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. - /// - public string? Cursor { get; init; } } diff --git a/src/OpenCode.Sdk/Sessions/SessionMessageUpdatePatchResponse.cs b/src/OpenCode.Sdk/Sessions/SessionMessageUpdatePatchResponse.cs deleted file mode 100644 index 6ecd85bb8..000000000 --- a/src/OpenCode.Sdk/Sessions/SessionMessageUpdatePatchResponse.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Generated by OpenCode.Sdk.Tools from spec/openapi.json. -// Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. -using System.Diagnostics.CodeAnalysis; -using System.Text; -using OpenCode.Sdk.Models; - -namespace OpenCode.Sdk; -/// -/// Represents the response of the 'PATCH /api/session/{sessionID}/message/{messageID}' operation. -/// -public sealed record SessionMessageUpdatePatchResponse : OpenCodeResponse -{ - private readonly SessionMessageAssistant? _messageUpdate; - /// - /// Initializes a success instance of the 'SessionMessageUpdatePatchResponse' envelope. - /// - public SessionMessageUpdatePatchResponse() - { - } - - /// - /// Initializes an error-path instance; the payload stays unset behind its guard. - /// - [SetsRequiredMembers] - internal SessionMessageUpdatePatchResponse(int status, IOpenCodeError? error, string? rawBody) - { - Status = status; - IsError = true; - Error = error; - RawBody = rawBody; - MessageUpdate = null!; - } - - /// - /// Gets the MessageUpdate payload; guarded on the error path. - /// - public required SessionMessageAssistant MessageUpdate { get => _messageUpdate ?? throw new InvalidOperationException("The response is an error; check IsError before accessing MessageUpdate."); init => _messageUpdate = value; } - - /// - /// Prints the shared metadata and appends the payload only when it is present. - /// - protected override bool PrintMembers(StringBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - var printed = base.PrintMembers(builder); - if (_messageUpdate is null) - { - return printed; - } - - if (printed) - { - _ = builder.Append(", "); - } - - _ = builder.Append("MessageUpdate = ").Append(_messageUpdate); - return true; - } -} diff --git a/src/OpenCode.Sdk/Sessions/SessionsClient.cs b/src/OpenCode.Sdk/Sessions/SessionsClient.cs index e871db7a5..7ad37ef57 100644 --- a/src/OpenCode.Sdk/Sessions/SessionsClient.cs +++ b/src/OpenCode.Sdk/Sessions/SessionsClient.cs @@ -1,6 +1,7 @@ // Generated by OpenCode.Sdk.Tools from spec/openapi.json. // Do not edit by hand β€” change tools/curation.json or the emitters, then regenerate. using OpenCode.Sdk.Internal; +using OpenCode.Sdk.Internal.Pagination; using OpenCode.Sdk.Internal.ResponseAdapters; using OpenCode.Sdk.Internal.Serialization; using OpenCode.Sdk.Models; @@ -99,6 +100,19 @@ public virtual Task ListSessionsAsync(SessionListRequest? r return Pipeline.ExecuteAsync(HttpMethod.Get, OpenCodeRoutes.Sessions.ListSessions(request), SessionListResponseAdapter.Instance, requestOptions, cancellationToken); } + /// + /// Enumerates the items returned by 'GET /api/session' by following each opaque next cursor; every filter of the first request rides each continuation. + /// + /// The request shaping the first page. + /// The cancellation token. + /// The 'SessionInfo' sequence, whose Pages expose each 'SessionListResponse'. + /// The API returned a declared error status (declared: 400, 401); pagination API errors always throw. + /// The server could not be reached or returned a malformed success body. + public virtual CursorSequence EnumerateSessionsAsync(SessionListRequest? request = null, CancellationToken cancellationToken = default) + { + return CursorPaginator.EnumerateAsync(ListSessionsAsync, request, SessionListResponseAdapter.Instance, cancellationToken); + } + /// /// Import session. Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children. /// diff --git a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/PaginationBenchmarks.cs b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/PaginationBenchmarks.cs index d2e55f50f..b4bc74036 100644 --- a/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/PaginationBenchmarks.cs +++ b/tests/OpenCode.Sdk.Performance.Tests/Benchmarks/PaginationBenchmarks.cs @@ -45,6 +45,7 @@ public async Task SetupAsync() _session = _client.Sessions.GetSessionClient("ses_bench0000000000000000001"); if (await EnumerateMessagesAsync().ConfigureAwait(false) != PageCount * ItemsPerPage + || await EnumerateMessagePagesAsync().ConfigureAwait(false) != PageCount * ItemsPerPage || await ListPagesManuallyAsync().ConfigureAwait(false) != PageCount * ItemsPerPage) { throw new InvalidOperationException("The canned pages did not traverse to the expected item count."); @@ -57,7 +58,7 @@ public async Task EnumerateMessagesAsync() { var session = _session!; var count = 0; - await foreach (var item in session.EnumerateMessagesAsync().ConfigureAwait(false)) + await foreach (var item in session.EnumerateMessagesAsync().WithCancellation(CancellationToken.None).ConfigureAwait(false)) { _ = item; count++; @@ -66,6 +67,20 @@ public async Task EnumerateMessagesAsync() return count; } + /// The same traversal read through the sequence's page door instead of its items. + [Benchmark] + public async Task EnumerateMessagePagesAsync() + { + var session = _session!; + var count = 0; + await foreach (var page in session.EnumerateMessagesAsync().Pages.WithCancellation(CancellationToken.None).ConfigureAwait(false)) + { + count += page.Messages.Count; + } + + return count; + } + /// The control: the same pages through explicit page calls and a hand-written loop. [Benchmark] public async Task ListPagesManuallyAsync() diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs index dd811fbbd..35255ab22 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientContractTests.cs @@ -1019,58 +1019,6 @@ public async Task PostFormReplyAsync_Should_Return_The_401_Error_On_The_NoThrow_ await Assert.That(response.Error).IsTypeOf(); } - [Test] - public async Task PatchMessageUpdateAsync_Should_Send_The_Tagged_Content_And_Return_The_Typed_Message() - { - const string message = "{\"id\":\"msg_1\",\"time\":{\"created\":1},\"agent\":\"build\"," - + "\"model\":{\"id\":\"gpt-4\",\"providerID\":\"openai\"},\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}"; - using var scenario = ContractScenario.Responding(HttpStatusCode.OK, WireBodyData.Envelope(message)); - - var response = await scenario.Client.Sessions.GetSessionClient("ses_100").PatchMessageUpdateAsync("msg_1", new SessionMessageUpdatePatchRequest - { - Content = [new SessionMessageAssistantText { Text = "hello", }], - }); - - await Assert.That(response.MessageUpdate.Id).IsEqualTo("msg_1"); - await Assert.That(response.MessageUpdate.Content.Single()).IsTypeOf(); - var request = scenario.Requests.Single(); - await Assert.That(request.Method.Method).IsEqualTo("PATCH"); - await Assert.That(request.RequestUri) - .IsEqualTo(new Uri("http://localhost:4096/api/session/ses_100/message/msg_1")); - await Assert.That(request.Body).IsEqualTo("{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}"); - } - - [Test] - public async Task PatchMessageUpdateAsync_Should_Throw_The_Declared_404_Message_Not_Found() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.NotFound, WireBodyData.MessageNotFoundError); - - var exception = await Assert - .That(async () => _ = await scenario.Client.Sessions.GetSessionClient("ses_100").PatchMessageUpdateAsync("msg_9", new SessionMessageUpdatePatchRequest - { - Content = [new SessionMessageAssistantText { Text = "hello", }], - })) - .Throws(); - - await Assert.That(exception!.Status).IsEqualTo(404); - await Assert.That(exception.Error).IsTypeOf(); - } - - [Test] - public async Task PatchMessageUpdateAsync_Should_Return_The_401_Error_On_The_NoThrow_Spine() - { - using var scenario = ContractScenario.Responding(HttpStatusCode.Unauthorized, WireBodyData.UnauthorizedError); - - var response = await scenario.Client.Sessions.GetSessionClient("ses_100").PatchMessageUpdateAsync( - "msg_1", - new SessionMessageUpdatePatchRequest { Content = [], }, - OpenCodeRequestOptions.NoThrow); - - await Assert.That(response.IsError).IsTrue(); - await Assert.That(response.Status).IsEqualTo(401); - await Assert.That(response.Error).IsTypeOf(); - } - [Test] public async Task PostViewAsync_Should_Send_The_Typed_Body_On_The_204() { diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientPaginationTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientPaginationTests.cs index 5b47f3a4d..8f04beaa5 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionClientPaginationTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionClientPaginationTests.cs @@ -1,3 +1,5 @@ +using OpenCode.Sdk.Models; + namespace OpenCode.Sdk.Tests; public sealed class SessionClientPaginationTests @@ -29,21 +31,221 @@ public async Task EnumerateMessagesAsync_Should_Use_The_Virtual_Page_Method_And_ await Assert.That(client.Requests[1]?.Cursor).IsEqualTo(string.Empty); } - private static MessageListResponse Page(string? next) => new() + [Test] + public async Task EnumerateMessagesAsync_Should_Yield_Every_Buffered_Item_In_Page_Order() + { + var client = new StubSessionClient( + Page("cur_1", Message("msg_1"), Message("msg_2")), + Page(next: null, Message("msg_3"))); + var received = new List(); + + await foreach (var message in client.EnumerateMessagesAsync()) + { + received.Add(((SessionMessageUser)message).Id); + } + + await Assert.That(received.SequenceEqual(["msg_1", "msg_2", "msg_3"], StringComparer.Ordinal)).IsTrue(); + } + + [Test] + public async Task Pages_Should_Yield_Each_Page_Envelope_Of_The_Same_Traversal() + { + var client = new StubSessionClient( + Page("cur_1", Message("msg_1")), + Page(next: null, Message("msg_2"))); + var received = new List(); + + await foreach (var page in client.EnumerateMessagesAsync().Pages.WithCancellation(CancellationToken.None)) + { + received.Add(page); + } + + await Assert.That(received.Count).IsEqualTo(2); + await Assert.That(received[0].Cursor.Next).IsEqualTo("cur_1"); + await Assert.That(received[1].Cursor.Next).IsNull(); + await Assert.That(received.All(static page => page.Status == 200)).IsTrue(); + await Assert.That(client.Requests.Count).IsEqualTo(2); + } + + [Test] + public async Task Pages_Should_Send_Its_Own_Requests_When_The_Sequence_Is_Enumerated_Twice() + { + var client = new StubSessionClient( + Page("cur_1", Message("msg_1")), + Page(next: null, Message("msg_2")), + Page("cur_1", Message("msg_1")), + Page(next: null, Message("msg_2"))); + var sequence = client.EnumerateMessagesAsync(); + var items = 0; + var pages = 0; + + await foreach (var _ in sequence) + { + items++; + } + + await foreach (var _ in sequence.Pages.WithCancellation(CancellationToken.None)) + { + pages++; + } + + await Assert.That(items).IsEqualTo(2); + await Assert.That(pages).IsEqualTo(2); + await Assert.That(client.Requests.Count).IsEqualTo(4); + } + + [Test] + public async Task GetAsyncEnumerator_Should_Observe_The_Token_Given_To_The_Companion() + { + using var cancellation = new CancellationTokenSource(); + var client = new StubSessionClient(Page(next: null, Message("msg_1"), Message("msg_2"))); + await using var enumerator = client + .EnumerateMessagesAsync(cancellationToken: cancellation.Token) + .GetAsyncEnumerator(); + + await Assert.That(await enumerator.MoveNextAsync()).IsTrue(); + await cancellation.CancelAsync(); + + await Assert.That(client.Tokens[0].IsCancellationRequested).IsTrue(); + _ = await Assert.That(async () => _ = await enumerator.MoveNextAsync()).Throws(); + } + + [Test] + public async Task GetAsyncEnumerator_Should_Observe_The_Token_Given_To_The_Enumerator() + { + using var cancellation = new CancellationTokenSource(); + var client = new StubSessionClient(Page(next: null, Message("msg_1"), Message("msg_2"))); + await using var enumerator = client + .EnumerateMessagesAsync(cancellationToken: CancellationToken.None) + .GetAsyncEnumerator(cancellation.Token); + + await Assert.That(await enumerator.MoveNextAsync()).IsTrue(); + await cancellation.CancelAsync(); + + await Assert.That(client.Tokens[0].IsCancellationRequested).IsTrue(); + _ = await Assert.That(async () => _ = await enumerator.MoveNextAsync()).Throws(); + } + + [Test] + public async Task Pages_Should_Surface_The_Failure_Of_The_Page_That_Raised_It() + { + var failure = new InvalidOperationException("page two failed"); + var client = new FailingSessionClient(Page("cur_1", Message("msg_1")), failure); + await using var enumerator = client.EnumerateMessagesAsync().Pages.GetAsyncEnumerator(); + + await Assert.That(await enumerator.MoveNextAsync()).IsTrue(); + await Assert.That(enumerator.Current.Cursor.Next).IsEqualTo("cur_1"); + + var thrown = await Assert.That(async () => _ = await enumerator.MoveNextAsync()).Throws(); + await Assert.That(thrown).IsSameReferenceAs(failure); + } + + [Test] + public async Task EnumerateSessionsAsync_Should_Carry_Every_Filter_And_Drop_Order_On_A_Continuation() + { + var client = new StubSessionsClient(SessionPage("cur_1"), SessionPage(next: null)); + var initialRequest = new SessionListRequest + { + Limit = "3", + Order = ListOrder.Descending, + Search = "needle", + Project = "prj_1", + }; + + await foreach (var _ in client.EnumerateSessionsAsync(initialRequest)) + { + // The traversal itself is the subject; the canned pages carry no sessions. + } + + await Assert.That(client.Requests.Count).IsEqualTo(2); + await Assert.That(ReferenceEquals(client.Requests[0], initialRequest)).IsTrue(); + await Assert.That(client.Requests[1]?.Limit).IsEqualTo("3"); + await Assert.That(client.Requests[1]?.Search).IsEqualTo("needle"); + await Assert.That(client.Requests[1]?.Project).IsEqualTo("prj_1"); + await Assert.That(client.Requests[1]?.Cursor).IsEqualTo("cur_1"); + await Assert.That(client.Requests[1]?.Order).IsNull(); + } + + [Test] + public async Task EnumerateSessionsAsync_Should_Start_A_Continuation_From_An_Absent_Request() + { + var client = new StubSessionsClient(SessionPage("cur_1"), SessionPage(next: null)); + + await foreach (var _ in client.EnumerateSessionsAsync().Pages.WithCancellation(CancellationToken.None)) + { + // Only the continuation request shape is under test. + } + + await Assert.That(client.Requests.Count).IsEqualTo(2); + await Assert.That(client.Requests[0]).IsNull(); + await Assert.That(client.Requests[1]?.Cursor).IsEqualTo("cur_1"); + await Assert.That(client.Requests[1]?.Limit).IsNull(); + await Assert.That(client.Requests[1]?.Order).IsNull(); + } + + private static MessageListResponse Page(string? next, params ISessionMessageInfo[] messages) => new() { Status = 200, - Messages = [], + Messages = messages, Cursor = new ListCursor { Next = next, }, }; + private static SessionListResponse SessionPage(string? next) => new() + { + Status = 200, + Sessions = [], + Cursor = new ListCursor { Next = next, }, + }; + + private static SessionMessageUser Message(string id) => new() + { + Id = id, + Text = id, + Time = new SessionMessageUserTime { Created = 0, }, + }; + private sealed class StubSessionClient(params MessageListResponse[] pages) : SessionClient { private readonly Queue _pages = new(pages); public List Requests { get; } = []; + public List Tokens { get; } = []; + + public override Task ListMessagesAsync(MessageListRequest? request = null, + OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + Requests.Add(request); + Tokens.Add(cancellationToken); + return Task.FromResult(_pages.Dequeue()); + } + } + + private sealed class FailingSessionClient(MessageListResponse firstPage, Exception failure) : SessionClient + { + private bool _served; + public override Task ListMessagesAsync(MessageListRequest? request = null, OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) + { + if (_served) + { + return Task.FromException(failure); + } + + _served = true; + return Task.FromResult(firstPage); + } + } + + private sealed class StubSessionsClient(params SessionListResponse[] pages) : SessionsClient + { + private readonly Queue _pages = new(pages); + + public List Requests { get; } = []; + + public override Task ListSessionsAsync(SessionListRequest? request = null, + OpenCodeRequestOptions? requestOptions = null, CancellationToken cancellationToken = default) { Requests.Add(request); return Task.FromResult(_pages.Dequeue()); diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs index ca7235f54..5c98a1121 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientContractTests.cs @@ -41,6 +41,110 @@ await Assert.That(scenario.Requests.Single().RequestUri!.AbsoluteUri).IsEqualTo( "http://localhost:4096/api/session?limit=5&order=desc&search=a%20b&parentID=null&cursor=cur_1"); } + [Test] + public async Task EnumerateSessionsAsync_Should_Carry_Every_Filter_Through_The_Opaque_Next_Cursors() + { + const string firstNext = "cur_next_1"; + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + var responses = new Queue( + [ + WireBodyData.Page(payload, next: firstNext), + WireBodyData.Page(payload), + ]); + using var scenario = ContractScenario.Responding(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responses.Dequeue()), + }); + var sequence = scenario.Client.Sessions.EnumerateSessionsAsync( + new SessionListRequest + { + Limit = "1", + Order = ListOrder.Descending, + Search = "a b", + ParentId = SessionParentFilter.RootOnly, + }, + CancellationToken.None); + + await Assert.That(scenario.Requests).IsEmpty(); + + var received = new List(); + await foreach (var session in sequence.WithCancellation(CancellationToken.None)) + { + received.Add(session); + } + + await Assert.That(received.Count).IsEqualTo(2); + await Assert.That(received.All(static session => session.Id == "ses_100")).IsTrue(); + await Assert + .That(scenario.Requests.Select(static request => request.RequestUri!.AbsoluteUri).SequenceEqual( + [ + "http://localhost:4096/api/session?limit=1&order=desc&search=a%20b&parentID=null", + $"http://localhost:4096/api/session?limit=1&search=a%20b&parentID=null&cursor={firstNext}", + ], + StringComparer.Ordinal)) + .IsTrue(); + } + + [Test] + public async Task EnumerateSessionsAsync_Should_Expose_Each_Page_Envelope_Through_Pages() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + var responses = new Queue( + [ + WireBodyData.Page(payload, next: "cur_next_1"), + WireBodyData.Page(payload), + ]); + using var scenario = ContractScenario.Responding(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responses.Dequeue()), + }); + var pages = new List(); + + await foreach (var page in scenario.Client.Sessions + .EnumerateSessionsAsync(cancellationToken: CancellationToken.None) + .Pages + .WithCancellation(CancellationToken.None)) + { + pages.Add(page); + } + + await Assert.That(pages.Count).IsEqualTo(2); + await Assert.That(pages[0].Status).IsEqualTo(200); + await Assert.That(pages[0].Cursor.Next).IsEqualTo("cur_next_1"); + await Assert.That(pages[0].Sessions.Single().Title).IsEqualTo("Fix the build"); + await Assert.That(pages[1].Cursor.Next).IsNull(); + await Assert.That(scenario.Requests.Count).IsEqualTo(2); + } + + [Test] + public async Task EnumerateSessionsAsync_Should_Throw_The_Declared_Error_When_A_Later_Page_Fails() + { + var payload = new FixtureLoader().LoadJson("Serialization.known-session.json"); + var responses = new Queue<(HttpStatusCode Status, string Body)>( + [ + (HttpStatusCode.OK, WireBodyData.Page(payload, next: "cur_invalid")), + (HttpStatusCode.BadRequest, WireBodyData.InvalidCursorError), + ]); + using var scenario = ContractScenario.Responding(_ => + { + var response = responses.Dequeue(); + return new HttpResponseMessage(response.Status) { Content = new StringContent(response.Body), }; + }); + await using var enumerator = scenario.Client.Sessions + .EnumerateSessionsAsync(cancellationToken: CancellationToken.None) + .GetAsyncEnumerator(); + + await Assert.That(await enumerator.MoveNextAsync()).IsTrue(); + await Assert.That(enumerator.Current.Id).IsEqualTo("ses_100"); + + var exception = await Assert + .That(async () => _ = await enumerator.MoveNextAsync()) + .Throws(); + + await Assert.That(exception?.Error).IsTypeOf(); + await Assert.That(scenario.Requests.Count).IsEqualTo(2); + } + [Test] [Arguments(" ")] [Arguments(".")] diff --git a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs index 0c4f935ec..465925450 100644 --- a/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs +++ b/tests/OpenCode.Sdk.Tests/Sessions/SessionsClientLiveTests.cs @@ -138,6 +138,74 @@ await Assert.That(response.Stats.Models.Any(model => } } + [Test] + [Timeout(180_000)] + public async Task EnumerateSessionsAsync_Should_Cross_A_Page_Boundary_Carrying_Its_Filter( + CancellationToken cancellationToken) + { + using var workspace = server.CreateWorkspace(); + using var client = server.CreateClient(new LocationSelector { Directory = workspace.Path }); + var first = await CreateOwnedSessionAsync(client, workspace.Path, "session-pagination-live-1", cancellationToken); + var second = await CreateOwnedSessionAsync(client, workspace.Path, "session-pagination-live-2", cancellationToken); + var firstSession = client.Sessions.GetSessionClient(first.Id); + var secondSession = client.Sessions.GetSessionClient(second.Id); + var cleanup = CreateCleanup(firstSession, first.Id); + cleanup.Own("second session removal", token => RemoveOwnedSessionAsync(secondSession, second.Id, token)); + Exception? primaryFailure = null; + + try + { + // A one-item page forces continuations, and the project filter must ride every one of + // them: dropping it would surface sessions from the other tests sharing this server. + var request = new SessionListRequest + { + Limit = "1", + Order = ListOrder.Ascending, + Project = first.ProjectId, + }; + var pages = new List(); + await foreach (var page in client.Sessions + .EnumerateSessionsAsync(request, cancellationToken) + .Pages + .WithCancellation(cancellationToken)) + { + pages.Add(page); + } + + var enumerated = new List(); + await foreach (var session in client.Sessions.EnumerateSessionsAsync(request, cancellationToken)) + { + enumerated.Add(session); + } + + await Assert.That(pages.Count).IsGreaterThan(1); + await Assert.That(pages.All(static page => page is { Status: 200, IsError: false })).IsTrue(); + await Assert.That(pages[^1].Cursor.Next).IsNull(); + await Assert + .That(pages + .SelectMany(static page => page.Sessions) + .Select(static session => session.Id) + .SequenceEqual(enumerated.Select(static session => session.Id), StringComparer.Ordinal)) + .IsTrue(); + await Assert.That(enumerated.All(session => session.ProjectId == first.ProjectId)).IsTrue(); + await Assert.That(enumerated.Any(session => session.Id == first.Id)).IsTrue(); + await Assert.That(enumerated.Any(session => session.Id == second.Id)).IsTrue(); + + Console.WriteLine( + "session-pagination-live: pages=" + Number(pages.Count) + + " sessions=" + Number(enumerated.Count) + + " project=" + first.ProjectId); + } + catch (Exception exception) + { + primaryFailure = exception; + } + finally + { + await cleanup.CompleteAsync(primaryFailure); + } + } + private static async Task CreateOwnedSessionAsync( OpenCodeClient client, string directory, diff --git a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt index 806b3ce17..e2c3c5ad9 100644 --- a/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt +++ b/tests/OpenCode.Sdk.Tests/Snapshots/PublicApi.verified.txt @@ -76,6 +76,12 @@ public virtual System.Threading.Tasks.Task RemoveCredentialAsync(string credentialId, OpenCode.Sdk.CredentialRemoveRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task UpdateCredentialAsync(string credentialId, OpenCode.Sdk.Models.CredentialUpdatePatchRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } } + public sealed class CursorSequence : System.Collections.Generic.IAsyncEnumerable + where TPage : OpenCode.Sdk.OpenCodeResponse + { + public System.Collections.Generic.IAsyncEnumerable Pages { get; } + public System.Collections.Generic.IAsyncEnumerator GetAsyncEnumerator(System.Threading.CancellationToken cancellationToken = default) { } + } public class DebugClient { protected DebugClient() { } @@ -414,6 +420,7 @@ public sealed class MessageListRequest : OpenCode.Sdk.ListRequest, System.IEquatable { public MessageListRequest() { } + public OpenCode.Sdk.Models.MessageListRequestType? Type { get; init; } } public sealed class MessageListResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { @@ -737,7 +744,6 @@ public const string ListMessagesTemplate = "/api/session/{sessionID}/message"; public const string ListRequestsTemplate = "/api/session/{sessionID}/permission"; public const string ListSessionsTemplate = "/api/session"; - public const string PatchMessageUpdateTemplate = "/api/session/{sessionID}/message/{messageID}"; public const string PostBackgroundTemplate = "/api/session/{sessionID}/background"; public const string PostCommandTemplate = "/api/session/{sessionID}/command"; public const string PostCompactTemplate = "/api/session/{sessionID}/compact"; @@ -785,7 +791,6 @@ public static string ListMessages(string sessionId, OpenCode.Sdk.MessageListRequest? request = null) { } public static string ListRequests(string sessionId) { } public static string ListSessions(OpenCode.Sdk.SessionListRequest? request = null) { } - public static string PatchMessageUpdate(string sessionId, string messageId) { } public static string PostBackground(string sessionId) { } public static string PostCommand(string sessionId) { } public static string PostCompact(string sessionId) { } @@ -1395,7 +1400,7 @@ public virtual System.Threading.Tasks.Task CreateFormAsync(OpenCode.Sdk.Models.SessionFormCreateRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task CreatePermissionAsync(OpenCode.Sdk.Models.SessionPermissionCreateRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task DeleteInboxCancelAsync(string inboxId, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Collections.Generic.IAsyncEnumerable EnumerateMessagesAsync(OpenCode.Sdk.MessageListRequest? request = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual OpenCode.Sdk.CursorSequence EnumerateMessagesAsync(OpenCode.Sdk.MessageListRequest? request = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task GetContextAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task GetExportAsync(OpenCode.Sdk.SessionExportRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task GetFormAsync(string formId, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -1409,7 +1414,6 @@ public virtual System.Threading.Tasks.Task ListInstructionsEntryAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task ListMessagesAsync(OpenCode.Sdk.MessageListRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task ListRequestsAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } - public virtual System.Threading.Tasks.Task PatchMessageUpdateAsync(string messageId, OpenCode.Sdk.Models.SessionMessageUpdatePatchRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task PostBackgroundAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task PostCommandAsync(OpenCode.Sdk.Models.SessionCommandPostRequest request, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task PostCompactAsync(OpenCode.Sdk.Models.SessionCompactPostRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -1569,13 +1573,10 @@ public required OpenCode.Sdk.Models.SessionInterruptResponse Interrupt { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } - public sealed class SessionListRequest : System.IEquatable + public sealed class SessionListRequest : OpenCode.Sdk.ListRequest, System.IEquatable { public SessionListRequest() { } - public string? Cursor { get; init; } public string? Directory { get; init; } - public string? Limit { get; init; } - public OpenCode.Sdk.ListOrder? Order { get; init; } public OpenCode.Sdk.SessionParentFilter? ParentId { get; init; } public string? Project { get; init; } public string? Search { get; init; } @@ -1601,12 +1602,6 @@ public required OpenCode.Sdk.Models.ISessionMessageInfo Message { get; init; } protected override bool PrintMembers(System.Text.StringBuilder builder) { } } - public sealed class SessionMessageUpdatePatchResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable - { - public SessionMessageUpdatePatchResponse() { } - public required OpenCode.Sdk.Models.SessionMessageAssistant MessageUpdate { get; init; } - protected override bool PrintMembers(System.Text.StringBuilder builder) { } - } public sealed class SessionMovePostResponse : OpenCode.Sdk.OpenCodeResponse, System.IEquatable { public SessionMovePostResponse() { } @@ -1721,6 +1716,7 @@ { protected SessionsClient() { } public virtual System.Threading.Tasks.Task CreateSessionAsync(OpenCode.Sdk.Models.SessionCreateRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } + public virtual OpenCode.Sdk.CursorSequence EnumerateSessionsAsync(OpenCode.Sdk.SessionListRequest? request = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual System.Threading.Tasks.Task GetActiveAsync(OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } public virtual OpenCode.Sdk.SessionClient GetSessionClient(string sessionId) { } public virtual System.Threading.Tasks.Task GetStatsAsync(OpenCode.Sdk.SessionStatsRequest? request = null, OpenCode.Sdk.OpenCodeRequestOptions? requestOptions = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -2901,12 +2897,6 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.SessionMessageAssistantJsonConverter))] - public interface ISessionMessageAssistant - { - [System.Text.Json.Serialization.JsonPropertyName("type")] - string Type { get; } - } [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.SessionMessageAssistantContentJsonConverter))] public interface ISessionMessageAssistantContent { @@ -3675,6 +3665,30 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("status")] public string Status { get; } } + [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] + public enum MessageListRequestType + { + [System.Text.Json.Serialization.JsonStringEnumMemberName("agent-switched")] + AgentSwitched = 0, + [System.Text.Json.Serialization.JsonStringEnumMemberName("model-switched")] + ModelSwitched = 1, + [System.Text.Json.Serialization.JsonStringEnumMemberName("location-switched")] + LocationSwitched = 2, + [System.Text.Json.Serialization.JsonStringEnumMemberName("user")] + User = 3, + [System.Text.Json.Serialization.JsonStringEnumMemberName("synthetic")] + Synthetic = 4, + [System.Text.Json.Serialization.JsonStringEnumMemberName("system")] + System = 5, + [System.Text.Json.Serialization.JsonStringEnumMemberName("skill")] + Skill = 6, + [System.Text.Json.Serialization.JsonStringEnumMemberName("shell")] + Shell = 7, + [System.Text.Json.Serialization.JsonStringEnumMemberName("assistant")] + Assistant = 8, + [System.Text.Json.Serialization.JsonStringEnumMemberName("compaction")] + Compaction = 9, + } public sealed class MessageNotFoundError : OpenCode.Sdk.Models.IOpenCodeError, System.IEquatable { public MessageNotFoundError() { } @@ -3797,6 +3811,9 @@ namespace OpenCode.Sdk.Models public required OpenCode.Sdk.Models.ModelInfoTime Time { get; init; } [System.Text.Json.Serialization.JsonPropertyName("variants")] public required System.Collections.Generic.IReadOnlyList Variants { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("websocket")] + public bool? Websocket { get; init; } } public sealed class ModelInfoLimit : System.IEquatable { @@ -4633,6 +4650,9 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("settings")] public System.Collections.Generic.IReadOnlyDictionary? Settings { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("websocket")] + public bool? Websocket { get; init; } } [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum ProviderInfoActivation @@ -5123,6 +5143,9 @@ namespace OpenCode.Sdk.Models { public SessionCompactionEndedData() { } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("cost")] + public double? Cost { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("model")] public OpenCode.Sdk.Models.ModelRef? Model { get; init; } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] @@ -5139,6 +5162,9 @@ namespace OpenCode.Sdk.Models public required string SessionId { get; init; } [System.Text.Json.Serialization.JsonPropertyName("text")] public required string Text { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("tokens")] + public OpenCode.Sdk.Models.TokenUsageInfo? Tokens { get; init; } } [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionCompactionEndedDataReason @@ -5181,6 +5207,9 @@ namespace OpenCode.Sdk.Models public sealed class SessionCompactionFailedData : System.IEquatable { public SessionCompactionFailedData() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("cost")] + public double? Cost { get; init; } [System.Text.Json.Serialization.JsonPropertyName("error")] public required OpenCode.Sdk.Models.SessionStructuredError Error { get; init; } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] @@ -5190,6 +5219,9 @@ namespace OpenCode.Sdk.Models public required OpenCode.Sdk.Models.SessionCompactionFailedDataReason Reason { get; init; } [System.Text.Json.Serialization.JsonPropertyName("sessionID")] public required string SessionId { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("tokens")] + public OpenCode.Sdk.Models.TokenUsageInfo? Tokens { get; init; } } [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.StrictJsonStringEnumConverter))] public enum SessionCompactionFailedDataReason @@ -6218,7 +6250,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonStringEnumMemberName("unknown")] Unknown = 5, } - public sealed class SessionMessageAssistantReasoning : OpenCode.Sdk.Models.ISessionMessageAssistant, OpenCode.Sdk.Models.ISessionMessageAssistantContent, OpenCode.Sdk.Models.ISessionMessageAssistantContentEncoded, System.IEquatable + public sealed class SessionMessageAssistantReasoning : OpenCode.Sdk.Models.ISessionMessageAssistantContent, OpenCode.Sdk.Models.ISessionMessageAssistantContentEncoded, System.IEquatable { public SessionMessageAssistantReasoning() { } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] @@ -6264,7 +6296,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("start")] public string? Start { get; init; } } - public sealed class SessionMessageAssistantText : OpenCode.Sdk.Models.ISessionMessageAssistant, OpenCode.Sdk.Models.ISessionMessageAssistantContent, OpenCode.Sdk.Models.ISessionMessageAssistantContentEncoded, System.IEquatable + public sealed class SessionMessageAssistantText : OpenCode.Sdk.Models.ISessionMessageAssistantContent, OpenCode.Sdk.Models.ISessionMessageAssistantContentEncoded, System.IEquatable { public SessionMessageAssistantText() { } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] @@ -6287,7 +6319,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("streamed")] public double? Streamed { get; init; } } - public sealed class SessionMessageAssistantTool : OpenCode.Sdk.Models.ISessionMessageAssistant, OpenCode.Sdk.Models.ISessionMessageAssistantContent, OpenCode.Sdk.Models.ISessionMessageAssistantContentEncoded, System.IEquatable + public sealed class SessionMessageAssistantTool : OpenCode.Sdk.Models.ISessionMessageAssistantContent, OpenCode.Sdk.Models.ISessionMessageAssistantContentEncoded, System.IEquatable { public SessionMessageAssistantTool() { } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] @@ -6325,6 +6357,9 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageCompactionCompleted : OpenCode.Sdk.Models.ISessionMessageCompaction, OpenCode.Sdk.Models.ISessionMessageInfo, System.IEquatable { public SessionMessageCompactionCompleted() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("cost")] + public double? Cost { get; init; } [System.Text.Json.Serialization.JsonPropertyName("id")] public required string Id { get; init; } [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] @@ -6349,6 +6384,9 @@ namespace OpenCode.Sdk.Models public required string Summary { get; init; } [System.Text.Json.Serialization.JsonPropertyName("time")] public required OpenCode.Sdk.Models.SessionMessageCompactionCompletedTime Time { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("tokens")] + public OpenCode.Sdk.Models.TokenUsageInfo? Tokens { get; init; } [System.Text.Json.Serialization.JsonPropertyName("type")] public string Type { get; } } @@ -6369,6 +6407,9 @@ namespace OpenCode.Sdk.Models public sealed class SessionMessageCompactionFailed : OpenCode.Sdk.Models.ISessionMessageCompaction, OpenCode.Sdk.Models.ISessionMessageInfo, System.IEquatable { public SessionMessageCompactionFailed() { } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("cost")] + public double? Cost { get; init; } [System.Text.Json.Serialization.JsonPropertyName("error")] public required OpenCode.Sdk.Models.SessionStructuredError Error { get; init; } [System.Text.Json.Serialization.JsonPropertyName("id")] @@ -6382,6 +6423,9 @@ namespace OpenCode.Sdk.Models public string Status { get; } [System.Text.Json.Serialization.JsonPropertyName("time")] public required OpenCode.Sdk.Models.SessionMessageCompactionFailedTime Time { get; init; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("tokens")] + public OpenCode.Sdk.Models.TokenUsageInfo? Tokens { get; init; } [System.Text.Json.Serialization.JsonPropertyName("type")] public string Type { get; } } @@ -6434,7 +6478,7 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("created")] public required double Created { get; init; } } - public sealed class SessionMessageContentUpdated : OpenCode.Sdk.Models.IEvent, OpenCode.Sdk.Models.ISessionEventDurable, OpenCode.Sdk.Models.ISessionLogItem, System.IEquatable + public sealed class SessionMessageContentUpdated : OpenCode.Sdk.Models.ISessionEventDurable, OpenCode.Sdk.Models.ISessionLogItem, System.IEquatable { public SessionMessageContentUpdated() { } [System.Text.Json.Serialization.JsonPropertyName("created")] @@ -6719,12 +6763,6 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("status")] public string Status { get; } } - public sealed class SessionMessageUpdatePatchRequest : System.IEquatable - { - public SessionMessageUpdatePatchRequest() { } - [System.Text.Json.Serialization.JsonPropertyName("content")] - public required System.Collections.Generic.IReadOnlyList Content { get; init; } - } public sealed class SessionMessageUser : OpenCode.Sdk.Models.ISessionMessageInfo, System.IEquatable { public SessionMessageUser() { } @@ -9093,15 +9131,6 @@ namespace OpenCode.Sdk.Models [System.Text.Json.Serialization.JsonPropertyName("type")] public string Type { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageAssistantJsonConverter))] - public sealed class UnknownSessionMessageAssistant : OpenCode.Sdk.Models.ISessionMessageAssistant, System.IEquatable - { - [System.Text.Json.Serialization.JsonConstructor] - public UnknownSessionMessageAssistant(string type, System.Text.Json.JsonElement payload) { } - public System.Text.Json.JsonElement Payload { get; } - [System.Text.Json.Serialization.JsonPropertyName("type")] - public string Type { get; } - } [System.Text.Json.Serialization.JsonConverter(typeof(OpenCode.Sdk.Internal.Serialization.UnknownSessionMessageAssistantContentJsonConverter))] public sealed class UnknownSessionMessageAssistantContent : OpenCode.Sdk.Models.ISessionMessageAssistantContent, System.IEquatable { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs index f4eaf2b03..b5d63a9b5 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/OperationPlanBinderTests.cs @@ -246,7 +246,7 @@ await Assert var list = sessions.Operations.Single(static operation => operation.MethodName == "ListSessionsAsync"); await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("SessionListRequest"); - await Assert.That(list.QueryRequest.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsTrue(); await Assert .That(list .QueryRequest.Properties.Select(static property => property.PropertyName) @@ -254,6 +254,12 @@ await Assert ["Workspace", "Limit", "Order", "Search", "ParentId", "Directory", "Project", "Subpath", "Cursor"], StringComparer.Ordinal)) .IsTrue(); + await Assert + .That(list + .QueryRequest.Properties.Where(static property => property.IsInherited) + .Select(static property => property.PropertyName) + .SequenceEqual(["Limit", "Order", "Cursor"], StringComparer.Ordinal)) + .IsTrue(); await Assert.That(list.Envelope!.Kind).IsEqualTo(EnvelopeKind.CursorList); await Assert.That(list.Envelope.PayloadName).IsEqualTo("Sessions"); await Assert.That(list.Envelope.PayloadType).IsTypeOf(); @@ -325,7 +331,6 @@ await Assert "ListInstructionsEntryAsync", "ListMessagesAsync", "ListRequestsAsync", - "PatchMessageUpdateAsync", "PostBackgroundAsync", "PostCommandAsync", "PostCompactAsync", @@ -497,7 +502,7 @@ await Assert } [Test] - public async Task Bind_Should_Recognize_Only_The_Selected_ListRequest_Cursor_Operation_As_Paginated() + public async Task Bind_Should_Recognize_Every_Pinned_ListRequest_Cursor_Operation_As_Paginated() { var plan = await new BindingTestHost().BindPinnedAsync(); var session = plan.Clients.Single(static client => client.Name == "SessionClient"); @@ -508,11 +513,19 @@ public async Task Bind_Should_Recognize_Only_The_Selected_ListRequest_Cursor_Ope await Assert.That(pagination).IsNotNull(); await Assert.That(pagination.MethodName).IsEqualTo("EnumerateMessagesAsync"); await Assert.That(pagination.RequestTypeName).IsEqualTo("MessageListRequest"); + await Assert.That(pagination.PageTypeName).IsEqualTo("MessageListResponse"); await Assert.That(pagination.ItemTypeName).IsEqualTo("ISessionMessageInfo"); await Assert.That(pagination.PayloadName).IsEqualTo("Messages"); - await Assert - .That(sessions.Operations.Single(static operation => operation.MethodName == "ListSessionsAsync").Pagination) - .IsNull(); + + var sessionPagination = sessions.Operations + .Single(static operation => operation.MethodName == "ListSessionsAsync") + .Pagination; + await Assert.That(sessionPagination).IsNotNull(); + await Assert.That(sessionPagination.MethodName).IsEqualTo("EnumerateSessionsAsync"); + await Assert.That(sessionPagination.RequestTypeName).IsEqualTo("SessionListRequest"); + await Assert.That(sessionPagination.PageTypeName).IsEqualTo("SessionListResponse"); + await Assert.That(sessionPagination.ItemTypeName).IsEqualTo("SessionInfo"); + await Assert.That(sessionPagination.PayloadName).IsEqualTo("Sessions"); } [Test] @@ -783,7 +796,7 @@ public async Task Bind_Should_Refuse_An_Optional_Request_Body() } [Test] - public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Into_A_Flat_Query_Request() + public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Beside_The_Inherited_Spine() { var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation .Parameter("limit", "query", QueryScenarioData.NullableString) @@ -797,7 +810,7 @@ public async Task Bind_Should_Bind_Optional_Nullable_Query_Parameters_Into_A_Fla var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); await Assert.That(list.QueryRequest).IsNotNull(); await Assert.That(list.QueryRequest!.TypeName).IsEqualTo("WidgetListRequest"); - await Assert.That(list.QueryRequest.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.DerivesFromListRequest).IsTrue(); await Assert .That(list .QueryRequest.Properties.Select(static property => property.WireName) @@ -819,7 +832,11 @@ await Assert QueryValueKind.SessionParentFilter, ])) .IsTrue(); - await Assert.That(list.QueryRequest.Properties.All(static property => !property.IsInherited)).IsTrue(); + await Assert + .That(list + .QueryRequest.Properties.Select(static property => property.IsInherited) + .SequenceEqual([true, true, true, false, false])) + .IsTrue(); await Assert.That(list.Parameters).IsEmpty(); } @@ -863,7 +880,7 @@ await Assert } [Test] - public async Task Bind_Should_Derive_The_Query_Request_From_The_List_Request_Base_When_The_Trio_Matches_Exactly() + public async Task Bind_Should_Derive_The_Query_Request_From_The_List_Request_Base_When_The_Query_Is_Only_The_Trio() { var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation .Parameter("limit", "query", QueryScenarioData.NullableString) @@ -884,13 +901,29 @@ await Assert } [Test] - public async Task Bind_Should_Keep_The_Query_Request_Flat_When_The_Trio_Has_Extra_Parameters() + public async Task Bind_Should_Keep_The_Query_Request_Flat_When_A_Spine_Parameter_Carries_An_Unadmitted_Schema() { var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", schema => schema.AnyOf( + branch => branch.Type("string").Enum("asc", "desc", "shuffled"), + branch => branch.Type("null"))) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsFalse(); + await Assert.That(list.QueryRequest.Properties.All(static property => !property.IsInherited)).IsTrue(); + } + + [Test] + public async Task Bind_Should_Keep_The_Query_Request_Flat_When_A_Spine_Parameter_Is_Declared_Required() + { + var document = await BindingTestHost.IngestAsync(WidgetListScenario(operation => operation + .Parameter("limit", "query", schema => schema.Type("string"), required: true) .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) - .Parameter("cursor", "query", QueryScenarioData.NullableString) - .Parameter("search", "query", QueryScenarioData.NullableString))); + .Parameter("cursor", "query", QueryScenarioData.NullableString))); var plan = BindWidgets(document); @@ -957,6 +990,59 @@ await Assert .IsTrue(); } + [Test] + public async Task Bind_Should_Admit_Cursor_Pagination_When_Filters_Ride_The_Spine() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString) + .Parameter("search", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsTrue(); + await Assert.That(list.Pagination).IsNotNull(); + await Assert.That(list.Pagination.MethodName).IsEqualTo("EnumerateWidgetsAsync"); + await Assert.That(list.Pagination.RequestTypeName).IsEqualTo("WidgetListRequest"); + await Assert.That(list.Pagination.PageTypeName).IsEqualTo("WidgetListResponse"); + await Assert.That(list.Pagination.ItemTypeName).IsEqualTo("WidgetInfo"); + } + + [Test] + public async Task Bind_Should_Leave_Cursor_Pagination_Absent_When_The_Spine_Is_Incomplete() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("cursor", "query", QueryScenarioData.NullableString))); + + var plan = BindWidgets(document); + + var list = plan.Clients.Single(static client => client.Role == ClientRole.Collection).Operations.Single(); + await Assert.That(list.QueryRequest!.DerivesFromListRequest).IsFalse(); + await Assert.That(list.Pagination).IsNull(); + } + + [Test] + public async Task Bind_Should_Refuse_Cursor_Pagination_When_A_Filter_Riding_The_Spine_Is_Required() + { + var document = await BindingTestHost.IngestAsync(CursorListScenario(configure: operation => operation + .Parameter("limit", "query", QueryScenarioData.NullableString) + .Parameter("order", "query", QueryScenarioData.NullableOrderEnum) + .Parameter("cursor", "query", QueryScenarioData.NullableString) + .Parameter("search", "query", schema => schema.Type("string"), required: true))); + + var exception = Assert.Throws(() => _ = BindWidgets(document)); + + await Assert + .That(exception.Errors.Any(static error => error.Category == BindingErrorCategory.Operation + && error.Problem.Contains( + "cursor pagination cannot continue a query that declares a required parameter", + StringComparison.Ordinal))) + .IsTrue(); + } + [Test] public async Task Bind_Should_Refuse_A_Cursor_Paginator_Whose_Enumeration_Name_Cannot_Be_Derived() { diff --git a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/PendingOperationBindabilityProbeTests.cs b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/PendingOperationBindabilityProbeTests.cs index c142d26b2..e733c1a7d 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/PendingOperationBindabilityProbeTests.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Generator/Binding/PendingOperationBindabilityProbeTests.cs @@ -118,7 +118,6 @@ await Assert.That(marks.Select(static mark => mark.OperationId) [Arguments("v2.session.form.get")] [Arguments("v2.session.form.reply")] [Arguments("v2.session.form.state")] - [Arguments("v2.session.messageUpdate")] [Arguments("v2.session.view")] [Arguments("v2.workspace.destroy")] public async Task Probe_Should_Mark_Every_Known_Wall_Free_Pending_Operation_As_Bindable(string operationId) diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt index 80382a0f0..d727089fb 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ClientEmitterTests.Emit_Should_Produce_Virtual_Delegating_Clients.verified.txt @@ -162,14 +162,14 @@ public class WidgetClient } /// - /// Enumerates the items returned by 'GET /api/widget/{widgetID}/item' by following each opaque next cursor. + /// Enumerates the items returned by 'GET /api/widget/{widgetID}/item' by following each opaque next cursor; every filter of the first request rides each continuation. /// /// The request shaping the first page. /// The cancellation token. - /// The 'ExampleItem' sequence. + /// The 'ExampleItem' sequence, whose Pages expose each 'WidgetItemListResponse'. /// The API returned a declared error status (declared: 404); pagination API errors always throw. /// The server could not be reached or returned a malformed success body. - public virtual IAsyncEnumerable EnumerateItemsAsync(ItemListRequest? request = null, CancellationToken cancellationToken = default) + public virtual CursorSequence EnumerateItemsAsync(ItemListRequest? request = null, CancellationToken cancellationToken = default) { return CursorPaginator.EnumerateAsync(ListItemsAsync, request, WidgetItemListResponseAdapter.Instance, cancellationToken); } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt index 4a4ae69e6..c933c952a 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt +++ b/tests/OpenCode.Sdk.Tools.Tests/Snapshots/ResponseAdapterEmitterTests.Emit_Should_Produce_Status_Mapped_Adapters.verified.txt @@ -188,14 +188,14 @@ internal sealed class WidgetItemListResponseAdapter : ResponseAdapter - /// Creates a continuation request carrying the initial limit and opaque next cursor. + /// Creates a continuation request carrying every initial filter beside the opaque next cursor, with the first-page-only order dropped. /// public ItemListRequest CreateNextRequest(ItemListRequest? initialRequest, string cursor) { ArgumentNullException.ThrowIfNull(cursor); - return new ItemListRequest + return (initialRequest ?? new())with { - Limit = initialRequest?.Limit, + Order = null, Cursor = cursor }; } diff --git a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs index 45a0ec8d5..92983ede7 100644 --- a/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs +++ b/tests/OpenCode.Sdk.Tools.Tests/Support/EmitterPlanFixture.cs @@ -492,6 +492,7 @@ private static OperationPlan CreateItemListOperation() => { MethodName = "EnumerateItemsAsync", RequestTypeName = "ItemListRequest", + PageTypeName = "WidgetItemListResponse", ItemTypeName = "ExampleItem", PayloadName = "Items", }, diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ListRequestSpinePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ListRequestSpinePolicy.cs new file mode 100644 index 000000000..6f27a1a78 --- /dev/null +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ListRequestSpinePolicy.cs @@ -0,0 +1,39 @@ +using OpenCode.Sdk.Tools.Generator.Binding.Models; + +namespace OpenCode.Sdk.Tools.Generator.Binding; + +/// +/// Decides which bound query properties the hand-written ListRequest base already +/// declares. The spine is recognized by inclusion: all three admitted members must be present +/// in their admitted shapes, while any further optional parameter binds beside them as its own +/// property, so a filtered list stays one request record with one continuation contract. A +/// missing member, or a member whose schema is not the admitted one, keeps the record flat. +/// +internal static class ListRequestSpinePolicy +{ + private static readonly string[] SpineWireNames = ["limit", "order", "cursor"]; + + /// Reports whether the bound query carries every admitted spine member. + public static bool CarriesSpine(IReadOnlyList properties) + { + ArgumentNullException.ThrowIfNull(properties); + + return Array.TrueForAll( + SpineWireNames, + wireName => properties.Any(property => + string.Equals(property.WireName, wireName, StringComparison.Ordinal) && IsSpineMember(property))); + } + + /// Reports whether one bound property is the base's own declaration rather than a filter. + public static bool IsSpineMember(QueryPropertyPlan property) + { + ArgumentNullException.ThrowIfNull(property); + + return property switch + { + { WireName: "limit" or "cursor", Kind: QueryValueKind.Text, IsRequired: false } => true, + { WireName: "order", Kind: QueryValueKind.ListOrder, IsRequired: false } => true, + _ => false, + }; + } +} diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PaginationPlan.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PaginationPlan.cs index 1449b58fc..5df7ccd9c 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PaginationPlan.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/Models/PaginationPlan.cs @@ -7,6 +7,9 @@ internal sealed record PaginationPlan public required string RequestTypeName { get; init; } + /// Gets the generated response envelope the traversal yields as one page. + public required string PageTypeName { get; init; } + public required string ItemTypeName { get; init; } public required string PayloadName { get; init; } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs index da4f3738a..b0ddcdd2b 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/PaginationFacetBinder.cs @@ -22,6 +22,7 @@ internal sealed class PaginationFacetBinder(OperationFacetContext context) { Kind: EnvelopeKind.CursorList, PayloadName: { } payloadName, + ResponseTypeName: { } pageTypeName, PayloadType: ListTypeReferencePlan { ElementType: NamedTypeReferencePlan { Name: { } itemTypeName } }, }) { @@ -44,6 +45,14 @@ internal sealed class PaginationFacetBinder(OperationFacetContext context) return _context.RefuseNull("cursor pagination cannot carry a declared header parameter"); } + // A continuation is the caller's first request with a new cursor, so the traversal must be + // able to start one from no request at all; a required filter has no value to carry there. + if (queryRequest.HasRequiredMember) + { + return _context.RefuseNull( + "cursor pagination cannot continue a query that declares a required parameter"); + } + var enumerationMethodName = OperationNamePolicy.EnumerationMethodName(methodName); if (enumerationMethodName is null) { @@ -58,6 +67,7 @@ internal sealed class PaginationFacetBinder(OperationFacetContext context) { MethodName = enumerationMethodName, RequestTypeName = queryRequest.TypeName, + PageTypeName = pageTypeName, ItemTypeName = itemTypeName, PayloadName = payloadName, }; diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs index 5c984d56d..2cf9a9537 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/QueryRequestFacetBinder.cs @@ -38,21 +38,24 @@ internal sealed class QueryRequestFacetBinder(OperationFacetContext context) return null; } - if (MatchesListRequestProfile(properties)) + var derivesFromListRequest = ListRequestSpinePolicy.CarriesSpine(properties); + if (derivesFromListRequest) { properties = [ - .. properties.Select(static property => property with - { - IsInherited = true - }) + .. properties.Select(static property => ListRequestSpinePolicy.IsSpineMember(property) + ? property with + { + IsInherited = true + } + : property) ]; } return new QueryRequestPlan { TypeName = OperationNamePolicy.RequestTypeName(_context.Operation), - DerivesFromListRequest = properties.Count > 0 && properties[0].IsInherited, + DerivesFromListRequest = derivesFromListRequest, Properties = properties, }; } @@ -159,16 +162,6 @@ private readonly record struct QueryEnumBinding(bool IsEnum, string? TypeName) }; } - /// - /// The fail-closed profile wall: an operation derives from the ListRequest base - /// only when its wire query parameters are exactly the optional cursor-pagination trio. - /// - private static bool MatchesListRequestProfile(List properties) => - properties.Count is 3 - && properties.Any(static property => property is { WireName: "limit", Kind: QueryValueKind.Text, IsRequired: false }) - && properties.Any(static property => property is { WireName: "order", Kind: QueryValueKind.ListOrder, IsRequired: false }) - && properties.Any(static property => property is { WireName: "cursor", Kind: QueryValueKind.Text, IsRequired: false }); - private QueryValueKind? ResolveQueryValueKind(SchemaNode value) { return _context.Resolve(value) switch diff --git a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs index 3a490bfcc..59f758078 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Binding/ReservedNamePolicy.cs @@ -34,6 +34,7 @@ internal static class ReservedNamePolicy /// public static readonly FrozenSet SpineTypeNames = FrozenSet.ToFrozenSet( [ + "CursorSequence", "ErrorBehavior", "IOpenCodeClientOptions", EffectStreamTypeNamePolicy.CauseMarkerInterface, diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/PaginationMethodEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/PaginationMethodEmitter.cs index 03abd29ac..2a6e68cf4 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/PaginationMethodEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/PaginationMethodEmitter.cs @@ -25,7 +25,10 @@ public static MethodDeclarationSyntax Emit(OperationPlan operation, EmissionMode SyntaxFactory.Argument(SyntaxFactory.IdentifierName(ReservedNamePolicy.CancellationTokenParameter))); return SyntaxFactory .MethodDeclaration( - TypeSyntaxEmitter.Generic("IAsyncEnumerable", TypeSyntaxEmitter.EmitNamed(pagination.ItemTypeName)), + TypeSyntaxEmitter.Generic( + "CursorSequence", + TypeSyntaxEmitter.EmitNamed(pagination.PageTypeName), + TypeSyntaxEmitter.EmitNamed(pagination.ItemTypeName)), pagination.MethodName) .WithModifiers(EmissionModifiers.Member(emission)) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( @@ -65,12 +68,12 @@ private static SyntaxTriviaList EmitDocumentation(OperationPlan operation, Pagin "The server could not be reached or returned a malformed success body.")); return EmissionSyntax.MemberDocumentation( $"Enumerates the items returned by '{operation.HttpMethod.ToUpperInvariant()} {operation.RouteTemplate}' " - + "by following each opaque next cursor.", + + "by following each opaque next cursor; every filter of the first request rides each continuation.", [ new DocumentedParameter(ReservedNamePolicy.RequestParameter, "The request shaping the first page."), new DocumentedParameter(ReservedNamePolicy.CancellationTokenParameter, "The cancellation token."), ], - $"The '{pagination.ItemTypeName}' sequence.", + $"The '{pagination.ItemTypeName}' sequence, whose Pages expose each '{pagination.PageTypeName}'.", exceptions); } } diff --git a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs index 5a8b998ab..a0cf642a6 100644 --- a/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs +++ b/tools/OpenCode.Sdk.Tools/Generator/Emission/ResponseAdapterEmitter.cs @@ -233,23 +233,27 @@ private static MethodDeclarationSyntax EmitCreateNextRequest(OperationPlan opera var requestTypeName = operation.Pagination!.RequestTypeName; var statements = new List(); statements.AddRange(EmissionSyntax.ArgumentNullGuard("cursor")); - statements.Add(SyntaxFactory.ReturnStatement( - SyntaxFactory.ObjectCreationExpression(TypeSyntaxEmitter.EmitNamed(requestTypeName)) - .WithInitializer(SyntaxFactory.InitializerExpression( - SyntaxKind.ObjectInitializerExpression, - SyntaxFactory.SeparatedList( - [ - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName("Limit"), - SyntaxFactory.ConditionalAccessExpression( - SyntaxFactory.IdentifierName("initialRequest"), - SyntaxFactory.MemberBindingExpression(SyntaxFactory.IdentifierName("Limit")))), - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName("Cursor"), - SyntaxFactory.IdentifierName("cursor")), - ]))))); + + // The whole first request rides the continuation, so a filter added to the query needs no + // emitter change; only the first-page-only order is dropped and the cursor replaced. + statements.Add(SyntaxFactory.ReturnStatement(SyntaxFactory.WithExpression( + SyntaxFactory.ParenthesizedExpression(SyntaxFactory.BinaryExpression( + SyntaxKind.CoalesceExpression, + SyntaxFactory.IdentifierName("initialRequest"), + SyntaxFactory.ImplicitObjectCreationExpression())), + SyntaxFactory.InitializerExpression( + SyntaxKind.WithInitializerExpression, + SyntaxFactory.SeparatedList( + [ + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Order"), + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)), + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName("Cursor"), + SyntaxFactory.IdentifierName("cursor")), + ]))))); return SyntaxFactory.MethodDeclaration(TypeSyntaxEmitter.EmitNamed(requestTypeName), "CreateNextRequest") .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList( @@ -261,7 +265,8 @@ private static MethodDeclarationSyntax EmitCreateNextRequest(OperationPlan opera ]))) .WithBody(SyntaxFactory.Block(statements)) .WithLeadingTrivia(EmissionSyntax.Documentation( - "Creates a continuation request carrying the initial limit and opaque next cursor.")); + "Creates a continuation request carrying every initial filter beside the opaque next cursor, " + + "with the first-page-only order dropped.")); } /// diff --git a/tools/generation-profile.txt b/tools/generation-profile.txt index bbd2a15c3..a16b03763 100644 --- a/tools/generation-profile.txt +++ b/tools/generation-profile.txt @@ -93,7 +93,6 @@ v2.session.interrupt v2.session.list v2.session.log v2.session.message -v2.session.messageUpdate v2.session.move v2.session.permission.create v2.session.permission.get