clawdforge/clients/csharp/tests/Clawdforge.Tests/SessionTests.cs
Kayos 692b48a6b2 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
2026-04-29 06:59:45 -07:00

435 lines
16 KiB
C#

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);
}
}
}