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
This commit is contained in:
Kayos 2026-04-29 06:59:45 -07:00
parent 42b1516bc3
commit 692b48a6b2
9 changed files with 946 additions and 3 deletions

View file

@ -24,13 +24,13 @@ From this directory:
dotnet pack src/Clawdforge/Clawdforge.csproj -c Release -o ./dist 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: Add it as a source on the consumer machine:
```sh ```sh
dotnet nuget add source /path/to/clawdforge/clients/csharp/dist \ dotnet nuget add source /path/to/clawdforge/clients/csharp/dist \
--name clawdforge-local --name clawdforge-local
dotnet add package Clawdforge --version 0.1.0 dotnet add package Clawdforge --version 0.2.0
``` ```
### Option B — project reference ### 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<SessionState> 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` ## DI / `IHttpClientFactory`
Inject an external `HttpClient` so it shares the host's 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 retain ownership of the stream, wrap it in a non-disposing adapter
before passing it in. 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<SessionState>` for the calling token.
### Admin (require admin bootstrap token) ### Admin (require admin bootstrap token)
```csharp ```csharp

View file

@ -12,7 +12,7 @@
<!-- NuGet metadata --> <!-- NuGet metadata -->
<PackageId>Clawdforge</PackageId> <PackageId>Clawdforge</PackageId>
<Version>0.1.0</Version> <Version>0.2.0</Version>
<Authors>Kayos</Authors> <Authors>Kayos</Authors>
<Description>C# / .NET 8 SDK for clawdforge — a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API.</Description> <Description>C# / .NET 8 SDK for clawdforge — a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>

View file

@ -252,6 +252,90 @@ public sealed class ForgeClient : IDisposable
await SendAsync(req, cancellationToken).ConfigureAwait(false); await SendAsync(req, cancellationToken).ConfigureAwait(false);
} }
// ---- v0.2 sessions -----------------------------------------------------
/// <summary>
/// <c>POST /sessions</c>. Create a new multi-turn ACPX session and return
/// a <see cref="Session"/> handle. The handle implements
/// <see cref="IAsyncDisposable"/>; <c>await using</c> is the canonical
/// usage pattern.
/// </summary>
/// <remarks>
/// <code>
/// await using var s = await forge.CreateSessionAsync(new CreateSessionOptions { Agent = "claude" });
/// var r = await s.TurnAsync("hello");
/// </code>
/// </remarks>
public async Task<Session> 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<CreateSessionResponse>(req, cancellationToken).ConfigureAwait(false);
return new Session(this, resp.SessionId, resp.Agent, resp.CreatedAt);
}
/// <summary>
/// <c>GET /sessions</c>. Lists every session the calling token can see
/// (per-app isolation enforced server-side).
/// </summary>
public async Task<IReadOnlyList<SessionState>> ListSessionsAsync(CancellationToken cancellationToken = default)
{
using var req = BuildRequest(HttpMethod.Get, "sessions");
var resp = await SendAndReadAsync<SessionListResponse>(req, cancellationToken).ConfigureAwait(false);
return resp.Sessions;
}
/// <summary>
/// <c>GET /sessions/{id}</c>. Fetch the current state of a session.
/// Cross-token access is rejected by the server with a 404, surfaced as
/// <see cref="ForgeApiException"/> with <see cref="ForgeApiException.StatusCode"/> = 404.
/// </summary>
public async Task<SessionState> 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<SessionState>(req, cancellationToken).ConfigureAwait(false);
}
internal async Task<TurnResult> 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<TurnResult>(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 --------------------------------------------------------- // ---- internals ---------------------------------------------------------
private HttpRequestMessage BuildRequest(HttpMethod method, string relativePath) private HttpRequestMessage BuildRequest(HttpMethod method, string relativePath)

View file

@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// Options for <see cref="ForgeClient.CreateSessionAsync"/> /
/// <c>POST /sessions</c>.
/// </summary>
public sealed class CreateSessionOptions
{
/// <summary>
/// Agent identifier passed through to ACPX. Defaults to <c>"claude"</c>.
/// </summary>
[JsonPropertyName("agent")]
public string Agent { get; init; } = "claude";
/// <summary>
/// Optional caller-supplied metadata stored alongside the session
/// ledger row. Persisted server-side as JSON; values must be
/// JSON-serializable.
/// </summary>
[JsonPropertyName("meta")]
public IReadOnlyDictionary<string, object?>? Meta { get; init; }
}
/// <summary>
/// Options for <see cref="Session.TurnAsync(string, TurnOptions?, System.Threading.CancellationToken)"/> /
/// <c>POST /sessions/{id}/turn</c>.
/// </summary>
public sealed class TurnOptions
{
/// <summary>
/// File tokens previously returned from
/// <see cref="ForgeClient.UploadFileAsync(string, int, System.Threading.CancellationToken)"/>
/// to attach to this turn.
/// </summary>
[JsonPropertyName("files")]
public IReadOnlyList<string>? Files { get; init; }
/// <summary>
/// Subprocess timeout in seconds. Server clamps to <c>5..600</c>.
/// </summary>
[JsonPropertyName("timeout_secs")]
public int? TimeoutSecs { get; init; }
}
/// <summary>Wire shape for <c>POST /sessions/{id}/turn</c> body.</summary>
internal sealed class TurnRequestBody
{
[JsonPropertyName("prompt")]
public required string Prompt { get; init; }
[JsonPropertyName("files")]
public IReadOnlyList<string>? Files { get; init; }
[JsonPropertyName("timeout_secs")]
public int? TimeoutSecs { get; init; }
}
/// <summary>Wire shape for <c>POST /sessions</c> response.</summary>
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; }
}
/// <summary>Wire shape for <c>GET /sessions</c>.</summary>
internal sealed class SessionListResponse
{
[JsonPropertyName("sessions")]
public List<SessionState> Sessions { get; init; } = new();
}

View file

@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// Server-side view of a session as returned by
/// <c>GET /sessions/{id}</c> and <c>GET /sessions</c>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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
);

View file

@ -0,0 +1,30 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// One entry in <see cref="TurnResult.Events"/>. Discriminated by
/// <see cref="Type"/> — typical values are <c>"text"</c>,
/// <c>"thinking"</c>, and <c>"tool_call"</c>. Fields not relevant to a
/// given event type are <c>null</c>.
/// </summary>
/// <param name="Type">
/// Event discriminator: <c>"text"</c>, <c>"thinking"</c>, <c>"tool_call"</c>,
/// or anything else ACPX emits in the future.
/// </param>
/// <param name="Content">
/// Text payload for <c>"text"</c> / <c>"thinking"</c> events.
/// </param>
/// <param name="Name">
/// Tool name for <c>"tool_call"</c> events (e.g. <c>"Read"</c>, <c>"Bash"</c>).
/// </param>
/// <param name="Args">Tool arguments for <c>"tool_call"</c> events.</param>
/// <param name="Result">Tool result for <c>"tool_call"</c> events.</param>
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
);

View file

@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
namespace Clawdforge.Models;
/// <summary>
/// Successful response body from <c>POST /sessions/{id}/turn</c>.
/// </summary>
/// <remarks>
/// <para>
/// v0.2 returns a turn's events as a complete batch when the turn finishes
/// — no server-sent events / streaming. Use <see cref="Text"/> to flatten
/// the text portion of the reply, or iterate <see cref="Events"/> directly
/// to inspect tool calls / thinking blocks.
/// </para>
/// <para>
/// Failure responses (HTTP 4xx / 5xx) are surfaced via
/// <see cref="Exceptions.ForgeApiException"/> rather than this type.
/// </para>
/// </remarks>
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<TurnEvent> Events,
[property: JsonPropertyName("stop_reason")] string StopReason,
[property: JsonPropertyName("duration_ms")] long DurationMs
)
{
/// <summary>
/// Concatenate the <see cref="TurnEvent.Content"/> of every
/// <c>"text"</c> event in <see cref="Events"/> into a single string.
/// Non-text events (thinking, tool calls) are skipped. Returns the
/// empty string when no text events are present.
/// </summary>
public string Text()
=> string.Concat(Events.Where(e => e.Type == "text").Select(e => e.Content ?? string.Empty));
}

View file

@ -0,0 +1,135 @@
using Clawdforge.Models;
namespace Clawdforge;
/// <summary>
/// A multi-turn conversation handle backed by a server-side ACPX session.
/// </summary>
/// <remarks>
/// <para>
/// Sessions are created via
/// <see cref="ForgeClient.CreateSessionAsync(CreateSessionOptions?, System.Threading.CancellationToken)"/>.
/// They implement <see cref="IAsyncDisposable"/> so the canonical pattern
/// is <c>await using</c>:
/// </para>
/// <code>
/// 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.
/// </code>
/// <para>
/// <see cref="CloseAsync"/> 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 <c>using</c> scope.
/// </para>
/// </remarks>
public sealed class Session : IAsyncDisposable
{
/// <summary>Server-assigned session identifier.</summary>
public string Id { get; }
/// <summary>Agent identifier the session was opened against.</summary>
public string Agent { get; }
/// <summary>Unix epoch seconds when the session was created.</summary>
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;
}
/// <summary>
/// <c>true</c> once <see cref="CloseAsync"/> has run successfully (or is
/// in flight). Reads use a memory barrier so concurrent observers see a
/// consistent value.
/// </summary>
public bool IsClosed => Volatile.Read(ref _closed) != 0;
/// <summary>
/// Send a turn against the session.
/// </summary>
/// <exception cref="InvalidOperationException">The session is closed.</exception>
public Task<TurnResult> TurnAsync(string prompt, CancellationToken ct = default)
=> TurnAsync(prompt, options: null, ct);
/// <summary>
/// Send a turn against the session with optional file attachments and
/// per-turn timeout.
/// </summary>
/// <exception cref="InvalidOperationException">The session is closed.</exception>
public async Task<TurnResult> 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);
}
/// <summary>
/// <c>GET /sessions/{Id}</c>. Convenience that forwards to
/// <see cref="ForgeClient.GetSessionAsync"/>.
/// </summary>
public Task<SessionState> StateAsync(CancellationToken ct = default)
=> _client.GetSessionAsync(Id, ct);
/// <summary>
/// <c>DELETE /sessions/{Id}</c>. 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.
/// </summary>
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;
}
}
/// <summary>
/// Implements <see cref="IAsyncDisposable"/>. Calls
/// <see cref="CloseAsync"/>; any exception is swallowed because the
/// caller has already left the <c>using</c> scope.
/// </summary>
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.
}
}
/// <summary>
/// Diagnostic string. Deliberately omits the embedded
/// <see cref="ForgeClient"/> reference so a logged <c>Session</c> can't
/// leak the bearer token via <see cref="ForgeClient"/>'s field.
/// </summary>
public override string ToString()
=> $"Session(Id={Id}, Agent={Agent}, Closed={IsClosed})";
}

View file

@ -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;
/// <summary>
/// v0.2 Session API tests. Same custom <see cref="HttpMessageHandler"/>
/// mock approach as <see cref="ForgeClientTests"/> — no extra deps.
/// </summary>
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<ForgeApiException>(() => 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<ForgeApiException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(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<ForgeApiException>(() => client.GetSessionAsync("ses_other"));
Assert.Equal(404, ex.StatusCode);
Assert.IsNotType<ForgeAuthException>(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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
public MockHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
public MockHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> handler)
: this((req, ct) => Task.FromResult(handler(req, ct))) { }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return _handler(request, cancellationToken);
}
}
}