- 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
435 lines
16 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|