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:
parent
42b1516bc3
commit
692b48a6b2
9 changed files with 946 additions and 3 deletions
|
|
@ -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<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`
|
||||
|
||||
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<SessionState>` for the calling token.
|
||||
|
||||
### Admin (require admin bootstrap token)
|
||||
|
||||
```csharp
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
<!-- NuGet metadata -->
|
||||
<PackageId>Clawdforge</PackageId>
|
||||
<Version>0.1.0</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<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>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
|
|
|
|||
|
|
@ -252,6 +252,90 @@ public sealed class ForgeClient : IDisposable
|
|||
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 ---------------------------------------------------------
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string relativePath)
|
||||
|
|
|
|||
78
clients/csharp/src/Clawdforge/Models/SessionOptions.cs
Normal file
78
clients/csharp/src/Clawdforge/Models/SessionOptions.cs
Normal 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();
|
||||
}
|
||||
23
clients/csharp/src/Clawdforge/Models/SessionState.cs
Normal file
23
clients/csharp/src/Clawdforge/Models/SessionState.cs
Normal 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
|
||||
);
|
||||
30
clients/csharp/src/Clawdforge/Models/TurnEvent.cs
Normal file
30
clients/csharp/src/Clawdforge/Models/TurnEvent.cs
Normal 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
|
||||
);
|
||||
37
clients/csharp/src/Clawdforge/Models/TurnResult.cs
Normal file
37
clients/csharp/src/Clawdforge/Models/TurnResult.cs
Normal 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));
|
||||
}
|
||||
135
clients/csharp/src/Clawdforge/Session.cs
Normal file
135
clients/csharp/src/Clawdforge/Session.cs
Normal 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})";
|
||||
}
|
||||
435
clients/csharp/tests/Clawdforge.Tests/SessionTests.cs
Normal file
435
clients/csharp/tests/Clawdforge.Tests/SessionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue