From 692b48a6b25e754ffbf35ed7f4d187da8b160218 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 29 Apr 2026 06:59:45 -0700 Subject: [PATCH] clients/csharp: v0.2 multi-turn Session API - Session implements IAsyncDisposable; await using is the canonical form - Interlocked.CompareExchange idempotency on CloseAsync (rollback on transient) - ForgeClient.CreateSessionAsync / ListSessionsAsync / GetSessionAsync - TurnResult.Text() helper, records throughout - Session.ToString redacts internal _client (no bearer leak) - SessionTests.cs: 12 tests covering await-using/idempotency/rollback/exception-still-closes/list/state/cross-token-404/redaction/regression - README "Multi-turn / Sessions (v0.2)" section - csproj bumped to 0.2.0 v0.1 surface unchanged. Spec: memory/spec-clawdforge-v0.2.md Server core: 940861f --- clients/csharp/README.md | 125 ++++- .../csharp/src/Clawdforge/Clawdforge.csproj | 2 +- clients/csharp/src/Clawdforge/ForgeClient.cs | 84 ++++ .../src/Clawdforge/Models/SessionOptions.cs | 78 ++++ .../src/Clawdforge/Models/SessionState.cs | 23 + .../csharp/src/Clawdforge/Models/TurnEvent.cs | 30 ++ .../src/Clawdforge/Models/TurnResult.cs | 37 ++ clients/csharp/src/Clawdforge/Session.cs | 135 ++++++ .../tests/Clawdforge.Tests/SessionTests.cs | 435 ++++++++++++++++++ 9 files changed, 946 insertions(+), 3 deletions(-) create mode 100644 clients/csharp/src/Clawdforge/Models/SessionOptions.cs create mode 100644 clients/csharp/src/Clawdforge/Models/SessionState.cs create mode 100644 clients/csharp/src/Clawdforge/Models/TurnEvent.cs create mode 100644 clients/csharp/src/Clawdforge/Models/TurnResult.cs create mode 100644 clients/csharp/src/Clawdforge/Session.cs create mode 100644 clients/csharp/tests/Clawdforge.Tests/SessionTests.cs diff --git a/clients/csharp/README.md b/clients/csharp/README.md index 9201c2d..684f318 100644 --- a/clients/csharp/README.md +++ b/clients/csharp/README.md @@ -24,13 +24,13 @@ From this directory: dotnet pack src/Clawdforge/Clawdforge.csproj -c Release -o ./dist ``` -That drops `Clawdforge.0.1.0.nupkg` (and `.snupkg` symbols) under `./dist/`. +That drops `Clawdforge.0.2.0.nupkg` (and `.snupkg` symbols) under `./dist/`. Add it as a source on the consumer machine: ```sh dotnet nuget add source /path/to/clawdforge/clients/csharp/dist \ --name clawdforge-local -dotnet add package Clawdforge --version 0.1.0 +dotnet add package Clawdforge --version 0.2.0 ``` ### Option B — project reference @@ -75,6 +75,113 @@ if (res.Result.ValueKind == JsonValueKind.Object) { } ``` +## Multi-turn / Sessions (v0.2) + +For workflows that need context across turns (step-by-step builds, iterative +debugging, long-running agent tasks) clawdforge v0.2 adds a `/sessions/*` +surface backed by [ACPX][acpx]. Each session is a server-side handle with +its own ledger row; turns against it are batched and returned as structured +events. + +The C# binding maps that to `IAsyncDisposable` — the canonical usage is +`await using` so the session is closed (`DELETE /sessions/{id}`) the moment +the scope exits, success or exception. + +```csharp +using Clawdforge; +using Clawdforge.Models; + +await using var forge = new ForgeClient(new ForgeOptions { + BaseUrl = "http://192.168.0.5:8800", + Token = Environment.GetEnvironmentVariable("CLAWDFORGE_TOKEN"), +}); + +await using (var s = await forge.CreateSessionAsync(new CreateSessionOptions { Agent = "claude" })) +{ + TurnResult r1 = await s.TurnAsync("Read README.md and summarize"); + Console.WriteLine(r1.Text()); + + TurnResult r2 = await s.TurnAsync("Now look at the auth flow", new TurnOptions { + Files = new[] { "ff_xyz" }, + TimeoutSecs = 120, + }); + + foreach (var e in r2.Events) + { + if (e.Type == "tool_call") Console.WriteLine($" tool: {e.Name}"); + } +} +// scope exit → DELETE /sessions/{id} +``` + +### Lifecycle helpers + +```csharp +// Inspect or list sessions visible to the calling token (server enforces +// per-app isolation — token A cannot see token B's sessions; cross-token +// requests come back as ForgeApiException with StatusCode = 404). +SessionState state = await forge.GetSessionAsync("ses_abc"); +IReadOnlyList all = await forge.ListSessionsAsync(); + +// Or fetch the live state from a Session you already hold: +var state2 = await s.StateAsync(); +``` + +### Idempotent close + transient rollback + +`Session.CloseAsync` uses an `Interlocked.CompareExchange` flag, so calling +it twice is safe — only the first call sends `DELETE`. If the DELETE itself +fails (e.g. transient 5xx) the closed flag is rolled back so the caller can +retry with a fresh `CloseAsync` call. `DisposeAsync` swallows close errors +because the caller has already exited the `using` scope and there's no +productive way to surface them. + +```csharp +var s = await forge.CreateSessionAsync(); +try +{ + var r = await s.TurnAsync("..."); +} +finally +{ + // Manual close path (instead of await using): + await s.CloseAsync(); // sends DELETE + await s.CloseAsync(); // no-op +} +``` + +### Throws after close + +Once a session is closed, `TurnAsync` throws `InvalidOperationException` +synchronously (no HTTP round-trip): + +```csharp +await s.CloseAsync(); +await s.TurnAsync("hi"); // -> InvalidOperationException("session ses_... is closed") +``` + +### `TurnResult.Text()` helper + +`TurnResult.Events` is the full structured stream — `thinking`, `text`, +`tool_call`. For the common "just give me what the model said" path, +`Text()` concatenates the `Content` of every `text` event: + +```csharp +var r = await s.TurnAsync("hi"); +string answer = r.Text(); // "Hello! How can I help?" +int turnIdx = r.TurnIndex; // 0-based +long ms = r.DurationMs; +string stop = r.StopReason; // "end_turn", "max_tokens", ... +``` + +### Diagnostic redaction + +`Session.ToString()` deliberately omits the embedded `ForgeClient` +reference so a logged session can't surface the bearer token through +field-walking. Output is `Session(Id=..., Agent=..., Closed=...)`. + +[acpx]: https://github.com/openclaw/acpx + ## DI / `IHttpClientFactory` Inject an external `HttpClient` so it shares the host's @@ -170,6 +277,20 @@ request completes — this matches the standard `HttpClient` / retain ownership of the stream, wrap it in a non-disposing adapter before passing it in. +### `CreateSessionAsync(CreateSessionOptions?, CancellationToken)` (v0.2) + +`POST /sessions` → `Session` handle (implements `IAsyncDisposable`). See +[Multi-turn / Sessions (v0.2)](#multi-turn--sessions-v02) above. + +### `GetSessionAsync(string id, CancellationToken)` (v0.2) + +`GET /sessions/{id}` → `SessionState`. Cross-token access returns 404 ⇒ +`ForgeApiException(StatusCode = 404)`. + +### `ListSessionsAsync(CancellationToken)` (v0.2) + +`GET /sessions` → `IReadOnlyList` for the calling token. + ### Admin (require admin bootstrap token) ```csharp diff --git a/clients/csharp/src/Clawdforge/Clawdforge.csproj b/clients/csharp/src/Clawdforge/Clawdforge.csproj index 7fda48e..0adebec 100644 --- a/clients/csharp/src/Clawdforge/Clawdforge.csproj +++ b/clients/csharp/src/Clawdforge/Clawdforge.csproj @@ -12,7 +12,7 @@ Clawdforge - 0.1.0 + 0.2.0 Kayos C# / .NET 8 SDK for clawdforge — a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API. MIT diff --git a/clients/csharp/src/Clawdforge/ForgeClient.cs b/clients/csharp/src/Clawdforge/ForgeClient.cs index ff6ca28..7bbbc0f 100644 --- a/clients/csharp/src/Clawdforge/ForgeClient.cs +++ b/clients/csharp/src/Clawdforge/ForgeClient.cs @@ -252,6 +252,90 @@ public sealed class ForgeClient : IDisposable await SendAsync(req, cancellationToken).ConfigureAwait(false); } + // ---- v0.2 sessions ----------------------------------------------------- + + /// + /// POST /sessions. Create a new multi-turn ACPX session and return + /// a handle. The handle implements + /// ; await using is the canonical + /// usage pattern. + /// + /// + /// + /// await using var s = await forge.CreateSessionAsync(new CreateSessionOptions { Agent = "claude" }); + /// var r = await s.TurnAsync("hello"); + /// + /// + public async Task CreateSessionAsync( + CreateSessionOptions? options = null, + CancellationToken cancellationToken = default) + { + using var req = BuildRequest(HttpMethod.Post, "sessions"); + // Always send a JSON body (even an empty object) so the server can + // pick up its defaults — matches /run + /admin/tokens behaviour. + req.Content = JsonContent.Create(options ?? new CreateSessionOptions(), options: JsonOpts); + var resp = await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + return new Session(this, resp.SessionId, resp.Agent, resp.CreatedAt); + } + + /// + /// GET /sessions. Lists every session the calling token can see + /// (per-app isolation enforced server-side). + /// + public async Task> ListSessionsAsync(CancellationToken cancellationToken = default) + { + using var req = BuildRequest(HttpMethod.Get, "sessions"); + var resp = await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + return resp.Sessions; + } + + /// + /// GET /sessions/{id}. Fetch the current state of a session. + /// Cross-token access is rejected by the server with a 404, surfaced as + /// with = 404. + /// + public async Task GetSessionAsync(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("session id is required", nameof(id)); + } + using var req = BuildRequest(HttpMethod.Get, "sessions/" + Uri.EscapeDataString(id)); + return await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + } + + internal async Task SessionTurnInternalAsync( + string id, + string prompt, + TurnOptions? options, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(prompt)) + { + throw new ArgumentException("prompt is required", nameof(prompt)); + } + + var body = new TurnRequestBody + { + Prompt = prompt, + Files = options?.Files, + TimeoutSecs = options?.TimeoutSecs, + }; + + using var req = BuildRequest(HttpMethod.Post, "sessions/" + Uri.EscapeDataString(id) + "/turn"); + req.Content = JsonContent.Create(body, options: JsonOpts); + return await SendAndReadAsync(req, cancellationToken).ConfigureAwait(false); + } + + internal async Task SessionCloseInternalAsync(string id, CancellationToken cancellationToken) + { + using var req = BuildRequest(HttpMethod.Delete, "sessions/" + Uri.EscapeDataString(id)); + // Server returns {"ok": true, "already_closed"?: bool}; the caller + // (Session.CloseAsync) only cares about success vs. failure, so we + // drop the body via SendAsync rather than typing the response. + using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false); + } + // ---- internals --------------------------------------------------------- private HttpRequestMessage BuildRequest(HttpMethod method, string relativePath) diff --git a/clients/csharp/src/Clawdforge/Models/SessionOptions.cs b/clients/csharp/src/Clawdforge/Models/SessionOptions.cs new file mode 100644 index 0000000..e149f06 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/SessionOptions.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Options for / +/// POST /sessions. +/// +public sealed class CreateSessionOptions +{ + /// + /// Agent identifier passed through to ACPX. Defaults to "claude". + /// + [JsonPropertyName("agent")] + public string Agent { get; init; } = "claude"; + + /// + /// Optional caller-supplied metadata stored alongside the session + /// ledger row. Persisted server-side as JSON; values must be + /// JSON-serializable. + /// + [JsonPropertyName("meta")] + public IReadOnlyDictionary? Meta { get; init; } +} + +/// +/// Options for / +/// POST /sessions/{id}/turn. +/// +public sealed class TurnOptions +{ + /// + /// File tokens previously returned from + /// + /// to attach to this turn. + /// + [JsonPropertyName("files")] + public IReadOnlyList? Files { get; init; } + + /// + /// Subprocess timeout in seconds. Server clamps to 5..600. + /// + [JsonPropertyName("timeout_secs")] + public int? TimeoutSecs { get; init; } +} + +/// Wire shape for POST /sessions/{id}/turn body. +internal sealed class TurnRequestBody +{ + [JsonPropertyName("prompt")] + public required string Prompt { get; init; } + + [JsonPropertyName("files")] + public IReadOnlyList? Files { get; init; } + + [JsonPropertyName("timeout_secs")] + public int? TimeoutSecs { get; init; } +} + +/// Wire shape for POST /sessions response. +internal sealed class CreateSessionResponse +{ + [JsonPropertyName("session_id")] + public string SessionId { get; init; } = string.Empty; + + [JsonPropertyName("agent")] + public string Agent { get; init; } = string.Empty; + + [JsonPropertyName("created_at")] + public long CreatedAt { get; init; } +} + +/// Wire shape for GET /sessions. +internal sealed class SessionListResponse +{ + [JsonPropertyName("sessions")] + public List Sessions { get; init; } = new(); +} diff --git a/clients/csharp/src/Clawdforge/Models/SessionState.cs b/clients/csharp/src/Clawdforge/Models/SessionState.cs new file mode 100644 index 0000000..42acd05 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/SessionState.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Server-side view of a session as returned by +/// GET /sessions/{id} and GET /sessions. +/// +/// +/// This is the per-app session ledger: which token spawned the session, +/// when it was created, last touched, how many turns ran against it, and +/// whether it has been closed. ACPX itself owns the session content; this +/// is clawdforge's bookkeeping shape. +/// +public sealed record SessionState( + [property: JsonPropertyName("session_id")] string SessionId, + [property: JsonPropertyName("agent")] string Agent, + [property: JsonPropertyName("app_name")] string AppName, + [property: JsonPropertyName("created_at")] long CreatedAt, + [property: JsonPropertyName("last_turn_at")] long? LastTurnAt, + [property: JsonPropertyName("turn_count")] int TurnCount, + [property: JsonPropertyName("closed_at")] long? ClosedAt +); diff --git a/clients/csharp/src/Clawdforge/Models/TurnEvent.cs b/clients/csharp/src/Clawdforge/Models/TurnEvent.cs new file mode 100644 index 0000000..a3e977c --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/TurnEvent.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// One entry in . Discriminated by +/// — typical values are "text", +/// "thinking", and "tool_call". Fields not relevant to a +/// given event type are null. +/// +/// +/// Event discriminator: "text", "thinking", "tool_call", +/// or anything else ACPX emits in the future. +/// +/// +/// Text payload for "text" / "thinking" events. +/// +/// +/// Tool name for "tool_call" events (e.g. "Read", "Bash"). +/// +/// Tool arguments for "tool_call" events. +/// Tool result for "tool_call" events. +public sealed record TurnEvent( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("content")] string? Content = null, + [property: JsonPropertyName("name")] string? Name = null, + [property: JsonPropertyName("args")] JsonElement? Args = null, + [property: JsonPropertyName("result")] JsonElement? Result = null +); diff --git a/clients/csharp/src/Clawdforge/Models/TurnResult.cs b/clients/csharp/src/Clawdforge/Models/TurnResult.cs new file mode 100644 index 0000000..d422a44 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Models/TurnResult.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Clawdforge.Models; + +/// +/// Successful response body from POST /sessions/{id}/turn. +/// +/// +/// +/// v0.2 returns a turn's events as a complete batch when the turn finishes +/// — no server-sent events / streaming. Use to flatten +/// the text portion of the reply, or iterate directly +/// to inspect tool calls / thinking blocks. +/// +/// +/// Failure responses (HTTP 4xx / 5xx) are surfaced via +/// rather than this type. +/// +/// +public sealed record TurnResult( + [property: JsonPropertyName("ok")] bool Ok, + [property: JsonPropertyName("session_id")] string SessionId, + [property: JsonPropertyName("turn_index")] int TurnIndex, + [property: JsonPropertyName("events")] IReadOnlyList Events, + [property: JsonPropertyName("stop_reason")] string StopReason, + [property: JsonPropertyName("duration_ms")] long DurationMs +) +{ + /// + /// Concatenate the of every + /// "text" event in into a single string. + /// Non-text events (thinking, tool calls) are skipped. Returns the + /// empty string when no text events are present. + /// + public string Text() + => string.Concat(Events.Where(e => e.Type == "text").Select(e => e.Content ?? string.Empty)); +} diff --git a/clients/csharp/src/Clawdforge/Session.cs b/clients/csharp/src/Clawdforge/Session.cs new file mode 100644 index 0000000..5f42060 --- /dev/null +++ b/clients/csharp/src/Clawdforge/Session.cs @@ -0,0 +1,135 @@ +using Clawdforge.Models; + +namespace Clawdforge; + +/// +/// A multi-turn conversation handle backed by a server-side ACPX session. +/// +/// +/// +/// Sessions are created via +/// . +/// They implement so the canonical pattern +/// is await using: +/// +/// +/// await using var s = await forge.CreateSessionAsync(new CreateSessionOptions { Agent = "claude" }); +/// var r1 = await s.TurnAsync("hello"); +/// var r2 = await s.TurnAsync("now do X"); +/// // session closed (DELETE /sessions/{id}) automatically when scope exits. +/// +/// +/// is idempotent — repeated calls are no-ops once +/// the session has been successfully closed. If the close call itself +/// fails (e.g. transient 5xx), the closed flag is rolled back so the +/// caller can retry. Disposal swallows the close error since the caller +/// has already exited the using scope. +/// +/// +public sealed class Session : IAsyncDisposable +{ + /// Server-assigned session identifier. + public string Id { get; } + + /// Agent identifier the session was opened against. + public string Agent { get; } + + /// Unix epoch seconds when the session was created. + public long CreatedAt { get; } + + private readonly ForgeClient _client; + private int _closed; // 0 / 1, mutated via Interlocked + + internal Session(ForgeClient client, string id, string agent, long createdAt) + { + _client = client; + Id = id; + Agent = agent; + CreatedAt = createdAt; + } + + /// + /// true once has run successfully (or is + /// in flight). Reads use a memory barrier so concurrent observers see a + /// consistent value. + /// + public bool IsClosed => Volatile.Read(ref _closed) != 0; + + /// + /// Send a turn against the session. + /// + /// The session is closed. + public Task TurnAsync(string prompt, CancellationToken ct = default) + => TurnAsync(prompt, options: null, ct); + + /// + /// Send a turn against the session with optional file attachments and + /// per-turn timeout. + /// + /// The session is closed. + public async Task TurnAsync(string prompt, TurnOptions? options, CancellationToken ct = default) + { + if (IsClosed) + { + throw new InvalidOperationException($"session {Id} is closed"); + } + return await _client.SessionTurnInternalAsync(Id, prompt, options, ct).ConfigureAwait(false); + } + + /// + /// GET /sessions/{Id}. Convenience that forwards to + /// . + /// + public Task StateAsync(CancellationToken ct = default) + => _client.GetSessionAsync(Id, ct); + + /// + /// DELETE /sessions/{Id}. Idempotent: subsequent calls after a + /// successful close are no-ops. If the underlying DELETE fails + /// (transient error), the closed flag is rolled back so a retry can + /// land cleanly. + /// + public async Task CloseAsync(CancellationToken ct = default) + { + if (Interlocked.CompareExchange(ref _closed, 1, 0) != 0) + { + return; + } + try + { + await _client.SessionCloseInternalAsync(Id, ct).ConfigureAwait(false); + } + catch + { + // Roll back so a transient failure can be retried. + Volatile.Write(ref _closed, 0); + throw; + } + } + + /// + /// Implements . Calls + /// ; any exception is swallowed because the + /// caller has already left the using scope. + /// + public async ValueTask DisposeAsync() + { + try + { + await CloseAsync().ConfigureAwait(false); + } + catch + { + // Disposal swallows close errors — caller already exited the + // scope, no productive way to surface this. + } + } + + /// + /// Diagnostic string. Deliberately omits the embedded + /// reference so a logged Session can't + /// leak the bearer token via 's field. + /// + public override string ToString() + => $"Session(Id={Id}, Agent={Agent}, Closed={IsClosed})"; +} diff --git a/clients/csharp/tests/Clawdforge.Tests/SessionTests.cs b/clients/csharp/tests/Clawdforge.Tests/SessionTests.cs new file mode 100644 index 0000000..82d1ada --- /dev/null +++ b/clients/csharp/tests/Clawdforge.Tests/SessionTests.cs @@ -0,0 +1,435 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Clawdforge; +using Clawdforge.Exceptions; +using Clawdforge.Models; +using Xunit; + +namespace Clawdforge.Tests; + +/// +/// v0.2 Session API tests. Same custom +/// mock approach as — no extra deps. +/// +public class SessionTests +{ + [Fact] + public async Task CreateAndAwaitUsingDisposes() + { + var calls = new List<(HttpMethod method, string path, string? body)>(); + + var handler = new MockHandler(async (req, ct) => + { + string? body = null; + if (req.Content is not null) + { + body = await req.Content.ReadAsStringAsync(ct); + } + calls.Add((req.Method, req.RequestUri!.AbsolutePath, body)); + + return req.Method.Method switch + { + "POST" => JsonResponse(new + { + session_id = "ses_abc", + agent = "claude", + created_at = 1714000000L, + }), + "DELETE" => JsonResponse(new { ok = true, already_closed = false }), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), + }; + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + await using (var s = await client.CreateSessionAsync(new CreateSessionOptions { Agent = "claude" })) + { + Assert.Equal("ses_abc", s.Id); + Assert.Equal("claude", s.Agent); + Assert.Equal(1714000000L, s.CreatedAt); + Assert.False(s.IsClosed); + } + + Assert.Equal(2, calls.Count); + Assert.Equal(HttpMethod.Post, calls[0].method); + Assert.Equal("/sessions", calls[0].path); + Assert.Equal(HttpMethod.Delete, calls[1].method); + Assert.Equal("/sessions/ses_abc", calls[1].path); + } + + [Fact] + public async Task CloseIdempotent_DeleteOnlyOnce() + { + var deletes = 0; + var handler = new MockHandler((req, ct) => + { + if (req.Method == HttpMethod.Delete) deletes++; + return req.Method.Method switch + { + "POST" => JsonResponse(new { session_id = "ses_x", agent = "claude", created_at = 1L }), + "DELETE" => JsonResponse(new { ok = true }), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), + }; + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var s = await client.CreateSessionAsync(); + await s.CloseAsync(); + Assert.True(s.IsClosed); + await s.CloseAsync(); // idempotent no-op + await s.CloseAsync(); // still no-op + await s.DisposeAsync(); + + Assert.Equal(1, deletes); + } + + [Fact] + public async Task CloseFailureRollsBackFlag() + { + var handler = new MockHandler((req, ct) => + { + return req.Method.Method switch + { + "POST" => JsonResponse(new { session_id = "ses_y", agent = "claude", created_at = 1L }), + "DELETE" => JsonResponse(new { detail = "boom" }, HttpStatusCode.InternalServerError), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), + }; + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var s = await client.CreateSessionAsync(); + await Assert.ThrowsAsync(() => s.CloseAsync()); + // Rolled back — IsClosed should be false so a retry is possible. + Assert.False(s.IsClosed); + // Subsequent call also throws (transient still failing) but doesn't + // wedge into a "permanently closed" state. + await Assert.ThrowsAsync(() => s.CloseAsync()); + Assert.False(s.IsClosed); + } + + [Fact] + public async Task TurnRoundTrip() + { + string? capturedTurnBody = null; + + var handler = new MockHandler(async (req, ct) => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/turn")) + { + capturedTurnBody = await req.Content!.ReadAsStringAsync(ct); + Assert.Equal("/sessions/ses_t/turn", req.RequestUri.AbsolutePath); + return JsonResponse(new + { + ok = true, + session_id = "ses_t", + turn_index = 0, + events = new object[] + { + new { type = "thinking", content = "..." }, + new { type = "text", content = "Hello, " }, + new { type = "text", content = "world." }, + }, + stop_reason = "end_turn", + duration_ms = 1234L, + }); + } + return req.Method.Method switch + { + "POST" => JsonResponse(new { session_id = "ses_t", agent = "claude", created_at = 1L }), + "DELETE" => JsonResponse(new { ok = true }), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), + }; + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + await using var s = await client.CreateSessionAsync(); + var r = await s.TurnAsync("hello", new TurnOptions + { + Files = new[] { "ff_a", "ff_b" }, + TimeoutSecs = 90, + }); + + Assert.True(r.Ok); + Assert.Equal("ses_t", r.SessionId); + Assert.Equal(0, r.TurnIndex); + Assert.Equal("end_turn", r.StopReason); + Assert.Equal(1234L, r.DurationMs); + Assert.Equal(3, r.Events.Count); + Assert.Equal("Hello, world.", r.Text()); + + Assert.NotNull(capturedTurnBody); + using var doc = JsonDocument.Parse(capturedTurnBody!); + Assert.Equal("hello", doc.RootElement.GetProperty("prompt").GetString()); + Assert.Equal(90, doc.RootElement.GetProperty("timeout_secs").GetInt32()); + var files = doc.RootElement.GetProperty("files"); + Assert.Equal(2, files.GetArrayLength()); + Assert.Equal("ff_a", files[0].GetString()); + } + + [Fact] + public async Task TurnAfterClose_Throws() + { + var handler = new MockHandler((req, ct) => req.Method.Method switch + { + "POST" => JsonResponse(new { session_id = "ses_c", agent = "claude", created_at = 1L }), + "DELETE" => JsonResponse(new { ok = true }), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var s = await client.CreateSessionAsync(); + await s.CloseAsync(); + var ex = await Assert.ThrowsAsync(() => s.TurnAsync("x")); + Assert.Contains("ses_c", ex.Message); + Assert.Contains("closed", ex.Message); + } + + [Fact] + public async Task WithSessionExceptionStillCloses() + { + var deletes = 0; + var handler = new MockHandler((req, ct) => + { + if (req.Method == HttpMethod.Delete) deletes++; + return req.Method.Method switch + { + "POST" when req.RequestUri!.AbsolutePath == "/sessions" + => JsonResponse(new { session_id = "ses_e", agent = "claude", created_at = 1L }), + "DELETE" => JsonResponse(new { ok = true }), + _ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed), + }; + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + await Assert.ThrowsAsync(async () => + { + await using var s = await client.CreateSessionAsync(); + throw new InvalidOperationException("user code blew up"); + }); + + Assert.Equal(1, deletes); + } + + [Fact] + public async Task ListSessions() + { + var handler = new MockHandler((req, ct) => + { + Assert.Equal(HttpMethod.Get, req.Method); + Assert.Equal("/sessions", req.RequestUri!.AbsolutePath); + return JsonResponse(new + { + sessions = new object[] + { + new + { + session_id = "ses_1", + agent = "claude", + app_name = "cauldron", + created_at = 1714000000L, + last_turn_at = 1714000100L, + turn_count = 3, + closed_at = (long?)null, + }, + new + { + session_id = "ses_2", + agent = "claude", + app_name = "cauldron", + created_at = 1714000200L, + last_turn_at = (long?)null, + turn_count = 0, + closed_at = 1714000300L, + }, + }, + }); + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var list = await client.ListSessionsAsync(); + Assert.Equal(2, list.Count); + Assert.Equal("ses_1", list[0].SessionId); + Assert.Equal(3, list[0].TurnCount); + Assert.Null(list[0].ClosedAt); + Assert.Equal(1714000300L, list[1].ClosedAt); + Assert.Null(list[1].LastTurnAt); + } + + [Fact] + public async Task GetSession() + { + var handler = new MockHandler((req, ct) => + { + Assert.Equal(HttpMethod.Get, req.Method); + Assert.Equal("/sessions/ses_g", req.RequestUri!.AbsolutePath); + return JsonResponse(new + { + session_id = "ses_g", + agent = "claude", + app_name = "cauldron", + created_at = 1L, + last_turn_at = 2L, + turn_count = 1, + closed_at = (long?)null, + }); + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var st = await client.GetSessionAsync("ses_g"); + Assert.Equal("ses_g", st.SessionId); + Assert.Equal("cauldron", st.AppName); + Assert.Equal(1, st.TurnCount); + } + + [Fact] + public async Task CrossToken_Is_404() + { + var handler = new MockHandler((req, ct) => + JsonResponse(new { detail = "session not found" }, HttpStatusCode.NotFound)); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var ex = await Assert.ThrowsAsync(() => client.GetSessionAsync("ses_other")); + Assert.Equal(404, ex.StatusCode); + Assert.IsNotType(ex); + Assert.Contains("session not found", ex.Body); + } + + [Fact] + public void TurnResult_Text_ConcatenatesTextEvents() + { + var r = new TurnResult( + Ok: true, + SessionId: "ses_x", + TurnIndex: 1, + Events: new[] + { + new TurnEvent("thinking", Content: "ignored"), + new TurnEvent("text", Content: "alpha "), + new TurnEvent("tool_call", Name: "Read"), + new TurnEvent("text", Content: "beta"), + new TurnEvent("text", Content: null), + }, + StopReason: "end_turn", + DurationMs: 0); + + Assert.Equal("alpha beta", r.Text()); + } + + [Fact] + public void Session_ToString_DoesNotLeakToken() + { + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_supersecret_42" }); + + // Construct via reflection — Session's ctor is internal, so this + // mirrors what CreateSessionAsync produces without spinning up the + // mock handler just for a string-format check. + var ctor = typeof(Session).GetConstructors( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0]; + var s = (Session)ctor.Invoke(new object[] { client, "ses_redact", "claude", 1714000000L }); + + var str = s.ToString(); + Assert.Contains("ses_redact", str); + Assert.Contains("claude", str); + Assert.Contains("Closed=False", str); + Assert.DoesNotContain("cf_supersecret_42", str); + Assert.DoesNotContain("forge.test", str); + } + + [Fact] + public async Task V0_1_RunUnchanged() + { + // Regression: v0.1 /run path must still work end-to-end with the + // session-extended client. Mirrors the original Run_SerializesSnakeCase + // test minimally — proves the v0.2 additions didn't perturb v0.1. + var handler = new MockHandler((req, ct) => + { + Assert.Equal(HttpMethod.Post, req.Method); + Assert.Equal("/run", req.RequestUri!.AbsolutePath); + return JsonResponse(new + { + ok = true, + result = new { hello = "world" }, + duration_ms = 42, + stop_reason = "end_turn", + }); + }); + + using var http = new HttpClient(handler); + using var client = new ForgeClient( + new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" }, + http); + + var res = await client.RunAsync(new RunRequest { Prompt = "hello" }); + Assert.True(res.Ok); + Assert.Equal(42, res.DurationMs); + Assert.Equal("world", res.Result.GetProperty("hello").GetString()); + } + + // ---- helpers ----------------------------------------------------------- + + private static HttpResponseMessage JsonResponse(object payload, HttpStatusCode status = HttpStatusCode.OK) + { + var json = JsonSerializer.Serialize(payload); + return new HttpResponseMessage(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + } + + private sealed class MockHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public MockHandler(Func> handler) + { + _handler = handler; + } + + public MockHandler(Func handler) + : this((req, ct) => Task.FromResult(handler(req, ct))) { } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return _handler(request, cancellationToken); + } + } +}