From 1138ed873c79013a27b9cf9003ee0bb9444e6131 Mon Sep 17 00:00:00 2001 From: github-aelf Date: Wed, 20 May 2026 15:40:36 +0800 Subject: [PATCH 1/2] Add durable correctness coverage for responses state --- .../Core/LlmSessionGAgentTests.cs | 141 ++++++++++++++++++ .../ResponsesAgentToolStateGAgentTests.cs | 60 ++++++++ ...gentToolStateCurrentStateProjectorTests.cs | 41 +++++ .../MainnetResponsesEndpointsTests.cs | 116 +++++++++++++- 4 files changed, 355 insertions(+), 3 deletions(-) diff --git a/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs b/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs index f598d5b43..bf61d2086 100644 --- a/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs +++ b/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs @@ -208,6 +208,54 @@ await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResult call.ResolvedAt.Should().NotBeNull(); } + [Fact] + public async Task ForwardedToolCallLifecycle_ShouldDurablyAdvanceFromPendingToReceivedToResolved() + { + var actor = CreateActor("resp_1"); + var emittedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00")); + var receivedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:01:00+00:00")); + var resolvedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:02:00+00:00")); + + await actor.HandleRegisterAsync(new RegisterResponseSessionRequested + { + Record = BuildRecord("resp_1"), + }); + + var call = BuildToolCall("call_1"); + call.EmittedAt = emittedAt.Clone(); + await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested + { + ResponseId = "resp_1", + Call = call, + }); + + await actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + SchemaHash = "schema-1", + Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""), + ReceivedAt = receivedAt.Clone(), + }); + + await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + ResolvedAt = resolvedAt.Clone(), + }); + + actor.State.LastAppliedEventVersion.Should().Be(4); + actor.State.LastEventId.Should().Be("resp_1:tool:call_1:resolved"); + actor.State.ForwardedToolCalls.Should().ContainSingle(); + var persisted = actor.State.ForwardedToolCalls[0]; + persisted.Status.Should().Be(LlmSessionForwardedToolCallStatus.Resolved); + persisted.EmittedAt.Should().Be(emittedAt); + persisted.ReceivedAt.Should().Be(receivedAt); + persisted.ResolvedAt.Should().Be(resolvedAt); + ResponsesJsonValues.ToBoundaryJson(persisted.Result).Should().Be("""{"temperature":28}"""); + } + [Fact] public async Task HandleReceiveForwardedToolResultAsync_ShouldRejectSchemaMismatch() { @@ -258,6 +306,51 @@ await actor.HandleUpdateStatusAsync(new UpdateResponseSessionStatusRequested .Which.Status.Should().Be(LlmSessionForwardedToolCallStatus.Cancelled); } + [Fact] + public async Task HandleReceiveForwardedToolResultAsync_ShouldRejectCancelledCall_AndPreserveTerminalState() + { + var actor = CreateActor("resp_1"); + await actor.HandleRegisterAsync(new RegisterResponseSessionRequested + { + Record = BuildRecord("resp_1"), + }); + await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested + { + ResponseId = "resp_1", + Call = BuildToolCall("call_1"), + }); + await actor.HandleUpdateStatusAsync(new UpdateResponseSessionStatusRequested + { + ResponseId = "resp_1", + Status = LlmSessionStatus.Cancelled, + }); + var versionAfterCancel = actor.State.LastAppliedEventVersion; + + var receive = () => actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + SchemaHash = "schema-1", + Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""), + }); + var resolve = () => actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + }); + + await receive.Should().ThrowAsync(); + await resolve.Should().ThrowAsync(); + + actor.State.LastAppliedEventVersion.Should().Be(versionAfterCancel); + actor.State.Record!.Status.Should().Be(LlmSessionStatus.Cancelled); + actor.State.ForwardedToolCalls.Should().ContainSingle(); + var call = actor.State.ForwardedToolCalls[0]; + call.Status.Should().Be(LlmSessionForwardedToolCallStatus.Cancelled); + call.Result.Should().BeNull(); + call.ResolvedAt.Should().BeNull(); + } + [Fact] public async Task HandleExpireResponseSessionAsync_ShouldExpirePendingToolCallsWithSyntheticError() { @@ -290,6 +383,54 @@ await actor.HandleExpireResponseSessionAsync(new ExpireResponseSessionRequested call.ReceivedAt.Should().NotBeNull(); } + [Fact] + public async Task HandleReceiveForwardedToolResultAsync_ShouldRejectExpiredCall_AndPreserveExpiredState() + { + var actor = CreateActor("resp_1"); + var record = BuildRecord("resp_1"); + record.CreatedAt = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-2)); + record.Ttl = Duration.FromTimeSpan(TimeSpan.FromHours(1)); + await actor.HandleRegisterAsync(new RegisterResponseSessionRequested + { + Record = record, + }); + await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested + { + ResponseId = "resp_1", + Call = BuildToolCall("call_1"), + }); + await actor.HandleExpireResponseSessionAsync(new ExpireResponseSessionRequested + { + ResponseId = "resp_1", + ObservedAt = Timestamp.FromDateTime(DateTime.UtcNow), + }); + var versionAfterExpire = actor.State.LastAppliedEventVersion; + + var receive = () => actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + SchemaHash = "schema-1", + Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""), + }); + var resolve = () => actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + }); + + await receive.Should().ThrowAsync(); + await resolve.Should().ThrowAsync(); + + actor.State.LastAppliedEventVersion.Should().Be(versionAfterExpire); + actor.State.Record!.Status.Should().Be(LlmSessionStatus.Expired); + actor.State.ForwardedToolCalls.Should().ContainSingle(); + var call = actor.State.ForwardedToolCalls[0]; + call.Status.Should().Be(LlmSessionForwardedToolCallStatus.Expired); + call.Result.Should().BeNull(); + call.ResolvedAt.Should().BeNull(); + } + private static LlmSessionGAgent CreateActor(string responseId) => GAgentServiceTestKit.CreateStatefulAgent( new InMemoryEventStore(), diff --git a/test/Aevatar.GAgentService.Tests/Core/ResponsesAgentToolStateGAgentTests.cs b/test/Aevatar.GAgentService.Tests/Core/ResponsesAgentToolStateGAgentTests.cs index 21f18cf58..11d401414 100644 --- a/test/Aevatar.GAgentService.Tests/Core/ResponsesAgentToolStateGAgentTests.cs +++ b/test/Aevatar.GAgentService.Tests/Core/ResponsesAgentToolStateGAgentTests.cs @@ -100,6 +100,66 @@ await actor.HandleRecordTaskAsync(new RecordResponsesTaskRequested .Should().Be("""{"status":"accepted"}"""); } + [Fact] + public async Task HandleApplyTodoWriteAsync_ShouldIgnoreDuplicateWrite_AndKeepDurableStateStable() + { + var actor = CreateActor(); + await RegisterAsync(actor); + + var observedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00")); + const string argumentsJson = """{"todos":[{"id":"todo-1","content":"Ship","status":"pending"}]}"""; + var apply = new ApplyResponsesTodoWriteRequested + { + ScopeId = "scope-1", + OwnerSubject = "owner-1", + SourceResponseId = "resp_1", + Arguments = ResponsesJsonValues.ParseBoundaryPayload(argumentsJson), + ObservedAt = observedAt, + }; + apply.TodoItems.AddRange(ResponsesTodoItemParser.Parse(argumentsJson, "resp_1", observedAt)); + + await actor.HandleApplyTodoWriteAsync(apply); + var versionAfterFirstWrite = actor.State.LastAppliedEventVersion; + var updatedAtAfterFirstWrite = actor.State.Record!.UpdatedAt!.Clone(); + + await actor.HandleApplyTodoWriteAsync(apply); + + actor.State.LastAppliedEventVersion.Should().Be(versionAfterFirstWrite); + actor.State.Record!.UpdatedAt.Should().Be(updatedAtAfterFirstWrite); + actor.State.TodoItems.Should().ContainSingle(); + actor.State.TodoItems[0].Id.Should().Be("todo-1"); + actor.State.TodoItems[0].SourceResponseId.Should().Be("resp_1"); + } + + [Fact] + public async Task HandleRecordTaskAsync_ShouldIgnoreDuplicateTaskRecord_AndReusePersistedTrace() + { + var actor = CreateActor(); + await RegisterAsync(actor); + + var command = new RecordResponsesTaskRequested + { + SourceResponseId = "resp_1", + TaskId = "task_1", + ChildActorId = "responses-agent-tools-scope-task-1", + Description = "summarize", + Arguments = ResponsesJsonValues.ParseBoundaryPayload("""{"prompt":"summarize"}"""), + Result = ResponsesJsonValues.ParseBoundaryPayload("""{"status":"accepted"}"""), + Status = ResponsesAgentToolTaskStatus.Accepted, + ObservedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00")), + }; + + await actor.HandleRecordTaskAsync(command); + var versionAfterFirstRecord = actor.State.LastAppliedEventVersion; + + await actor.HandleRecordTaskAsync(command); + + actor.State.LastAppliedEventVersion.Should().Be(versionAfterFirstRecord); + actor.State.TaskTraces.Should().ContainSingle(); + actor.State.TaskTraces[0].TaskId.Should().Be("task_1"); + actor.State.TaskTraces[0].Status.Should().Be(ResponsesAgentToolTaskStatus.Accepted); + } + private static ResponsesAgentToolStateGAgent CreateActor() => GAgentServiceTestKit.CreateStatefulAgent( new InMemoryEventStore(), diff --git a/test/Aevatar.GAgentService.Tests/Projection/ResponsesAgentToolStateCurrentStateProjectorTests.cs b/test/Aevatar.GAgentService.Tests/Projection/ResponsesAgentToolStateCurrentStateProjectorTests.cs index d870fd2c0..98ede41c3 100644 --- a/test/Aevatar.GAgentService.Tests/Projection/ResponsesAgentToolStateCurrentStateProjectorTests.cs +++ b/test/Aevatar.GAgentService.Tests/Projection/ResponsesAgentToolStateCurrentStateProjectorTests.cs @@ -57,6 +57,47 @@ await projector.ProjectAsync( cache!.ResultJson.Should().Be("""{"content":"fresh"}"""); } + [Fact] + public async Task QueryReader_ShouldRemapFreshSnapshotFromPersistedReadModelAcrossRepeatedReads() + { + var store = new RecordingDocumentStore(x => x.Id); + var projector = new ResponsesAgentToolStateCurrentStateProjector( + store, + new FixedProjectionClock(DateTimeOffset.Parse("2026-05-12T00:00:00+00:00"))); + var observedAt = DateTimeOffset.Parse("2026-05-12T00:01:00+00:00"); + + await projector.ProjectAsync( + new ResponsesAgentToolStateCurrentStateProjectionContext + { + RootActorId = ActorId, + ProjectionKind = "responses-agent-tools", + }, + WrapCommittedState(observedAt)); + + var reader = new ResponsesAgentToolStateQueryReader(store); + var first = await reader.GetAsync(ScopeId, OwnerSubject); + var persisted = await store.GetAsync(ActorId); + + persisted.Should().NotBeNull(); + persisted!.Todos[0].Content = "Store changed"; + persisted.Tasks[0].ChildActorId = "child-2"; + persisted.WebCacheEntries[0].HitCount = 9; + + var second = await new ResponsesAgentToolStateQueryReader(store).GetAsync(ScopeId, OwnerSubject); + + first.Should().NotBeNull(); + second.Should().NotBeNull(); + first!.Todos.Should().ContainSingle(x => x.Content == "Ship"); + first.Tasks.Should().ContainSingle(x => x.ChildActorId == "child-1"); + first.WebCacheEntries.Should().ContainSingle(x => x.HitCount == 0); + second.Should().NotBeSameAs(first); + second!.ActorId.Should().Be(ActorId); + second.StateVersion.Should().Be(4); + second.Todos.Should().ContainSingle(x => x.Id == "todo-1" && x.Content == "Store changed"); + second.Tasks.Should().ContainSingle(x => x.TaskId == "task_1" && x.ChildActorId == "child-2"); + second.WebCacheEntries.Should().ContainSingle(x => x.CacheKey == "cache-1" && x.HitCount == 9); + } + private static EventEnvelope WrapCommittedState(DateTimeOffset observedAt) { var state = new ResponsesAgentToolState diff --git a/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs b/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs index 80c6212d9..b65ba6436 100644 --- a/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs +++ b/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs @@ -773,6 +773,72 @@ public async Task PostResponses_WithDuplicateResolvedToolResult_ShouldReturnWith sessions.ResolvedToolResults.Should().BeEmpty(); } + [Fact] + public async Task PostResponses_WithExpiredForwardedToolCall_ShouldReturnToolCallNotAvailable_WithoutCallingProvider() + { + var provider = new RecordingLLMProvider(); + var sessions = new RecordingResponseSessionStore(); + var schemaHash = ResponsesToolSchemaHashes.Compute("""{"type":"object"}"""); + sessions.Seed(new LlmSessionSnapshot( + "resp_previous", + "user-1", + "user-1", + LlmSessionOriginKind.ApiKey, + null, + LlmSessionStatus.Completed, + DateTimeOffset.UtcNow.AddMinutes(-2), + TimeSpan.FromHours(1), + null, + "response-session:resp_previous", + 3, + "resp_previous:tool:call_1:expired", + [ + new LlmSessionForwardedToolCallSnapshot( + "call_1", + "get_weather", + schemaHash, + """{"city":"Singapore"}""", + LlmSessionForwardedToolCallStatus.Expired, + DateTimeOffset.UtcNow.AddMinutes(-1), + """{"error":"tool_call_expired","call_id":"call_1"}""", + DateTimeOffset.UtcNow.AddHours(-1), + DateTimeOffset.UtcNow.AddMinutes(-1), + null), + ])); + await using var app = await CreateAppAsync(provider, sessions); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses") + { + Content = JsonContent($$""" + { + "model": "gpt-5.4", + "previous_response_id": "resp_previous", + "input": [ + { + "type": "function_call_output", + "call_id": "call_1", + "schema_hash": "{{schemaHash}}", + "output": {"temperature": 28} + } + ] + } + """), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token"); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body); + GetErrorCode(body).Should().Be("tool_call_not_available"); + body.Should().NotContain("secret-token"); + sessions.ToolResults.Should().BeEmpty(); + sessions.ResolvedToolResults.Should().BeEmpty(); + sessions.Registered.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + [Fact] public async Task PostResponses_WithFunctionCallOutputSchemaMismatch_ShouldReturnBadRequest() { @@ -883,6 +949,10 @@ public async Task PostResponses_WithPreviousResponseId_ShouldRegisterLinkedSessi doc.RootElement.GetProperty("previous_response_id").GetString().Should().Be("resp_previous"); sessions.Registered.Should().ContainSingle(); sessions.Registered[0].PreviousResponseId.Should().Be("resp_previous"); + provider.LastRequest.Should().NotBeNull(); + provider.LastRequest!.Messages.Should().ContainSingle(); + provider.LastRequest.Messages[0].Role.Should().Be("user"); + provider.LastRequest.Messages[0].Content.Should().Be("continue"); } [Fact] @@ -916,7 +986,7 @@ public async Task PostResponses_WithExpiredPreviousResponse_ShouldRejectResume() var body = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body); - body.Should().Contain("previous_response_expired"); + GetErrorCode(body).Should().Be("previous_response_expired"); sessions.Registered.Should().BeEmpty(); provider.LastRequest.Should().BeNull(); } @@ -952,7 +1022,7 @@ public async Task PostResponses_WithPreviousResponseFromDifferentScope_ShouldRet var body = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.Forbidden, body); - body.Should().Contain("response_scope_mismatch"); + GetErrorCode(body).Should().Be("response_scope_mismatch"); sessions.Registered.Should().BeEmpty(); provider.LastRequest.Should().BeNull(); } @@ -988,7 +1058,7 @@ public async Task PostResponses_WithPreviousResponseFromDifferentOrigin_ShouldRe var body = await response.Content.ReadAsStringAsync(); response.StatusCode.Should().Be(HttpStatusCode.Forbidden, body); - body.Should().Contain("response_origin_mismatch"); + GetErrorCode(body).Should().Be("response_origin_mismatch"); sessions.Registered.Should().BeEmpty(); provider.LastRequest.Should().BeNull(); } @@ -1045,6 +1115,40 @@ public async Task PostResponsesCancel_ShouldMarkResponseAndPendingToolCallsCance provider.LastRequest.Should().BeNull(); } + [Fact] + public async Task PostResponsesCancel_WithExpiredResponse_ShouldReturnStructuredExpiredError() + { + var provider = new RecordingLLMProvider(); + var sessions = new RecordingResponseSessionStore(); + sessions.Seed(new LlmSessionSnapshot( + "resp_expired", + "user-1", + "user-1", + LlmSessionOriginKind.ApiKey, + null, + LlmSessionStatus.Expired, + DateTimeOffset.UtcNow.AddHours(-2), + TimeSpan.FromHours(1), + null, + "response-session:resp_expired", + 2, + "resp_expired:status:5")); + await using var app = await CreateAppAsync(provider, sessions); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses/resp_expired/cancel"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token"); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body); + GetErrorCode(body).Should().Be("response_expired"); + body.Should().NotContain("secret-token"); + sessions.StatusUpdates.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + [Fact] public async Task PostResponses_WithoutBearer_ShouldReturnUnauthorized() { @@ -1536,6 +1640,12 @@ private static async Task CreateAppAsync( private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + private static string? GetErrorCode(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty("error").GetProperty("code").GetString(); + } + private sealed class RecordingLLMProvider : ILLMProvider, ILLMProviderFactory { public string Name => "recording"; From 0df3352835f5d97366ea195ab49cf24034967ed4 Mon Sep 17 00:00:00 2001 From: github-aelf Date: Wed, 20 May 2026 16:26:05 +0800 Subject: [PATCH 2/2] Add durable response terminal state tests --- .../Core/LlmSessionGAgentTests.cs | 46 ++++++++ .../MainnetResponsesEndpointsTests.cs | 103 ++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs b/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs index bf61d2086..2bf3e8ef5 100644 --- a/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs +++ b/test/Aevatar.GAgentService.Tests/Core/LlmSessionGAgentTests.cs @@ -208,6 +208,52 @@ await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResult call.ResolvedAt.Should().NotBeNull(); } + [Fact] + public async Task HandleResolveForwardedToolResultAsync_WhenCallIsAlreadyResolved_ShouldPreserveFirstResolvedAt() + { + var actor = CreateActor("resp_1"); + var firstResolvedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:02:00+00:00")); + var secondResolvedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.Parse("2026-05-12T00:03:00+00:00")); + + await actor.HandleRegisterAsync(new RegisterResponseSessionRequested + { + Record = BuildRecord("resp_1"), + }); + await actor.HandleRecordForwardedToolCallAsync(new RecordForwardedToolCallRequested + { + ResponseId = "resp_1", + Call = BuildToolCall("call_1"), + }); + await actor.HandleReceiveForwardedToolResultAsync(new ReceiveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + SchemaHash = "schema-1", + Result = ResponsesJsonValues.ParseBoundaryPayload("""{"temperature":28}"""), + }); + + await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + ResolvedAt = firstResolvedAt, + }); + var versionAfterFirstResolve = actor.State.LastAppliedEventVersion; + + await actor.HandleResolveForwardedToolResultAsync(new ResolveForwardedToolResultRequested + { + ResponseId = "resp_1", + CallId = "call_1", + ResolvedAt = secondResolvedAt, + }); + + actor.State.LastAppliedEventVersion.Should().Be(versionAfterFirstResolve); + var call = actor.State.ForwardedToolCalls.Should().ContainSingle().Which; + call.Status.Should().Be(LlmSessionForwardedToolCallStatus.Resolved); + call.ResolvedAt.Should().Be(firstResolvedAt); + ResponsesJsonValues.ToBoundaryJson(call.Result).Should().Be("""{"temperature":28}"""); + } + [Fact] public async Task ForwardedToolCallLifecycle_ShouldDurablyAdvanceFromPendingToReceivedToResolved() { diff --git a/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs b/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs index b65ba6436..da52f89e8 100644 --- a/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs +++ b/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs @@ -839,6 +839,72 @@ public async Task PostResponses_WithExpiredForwardedToolCall_ShouldReturnToolCal provider.LastRequest.Should().BeNull(); } + [Fact] + public async Task PostResponses_WithCancelledForwardedToolCall_ShouldReturnToolCallNotAvailable_WithoutCallingProvider() + { + var provider = new RecordingLLMProvider(); + var sessions = new RecordingResponseSessionStore(); + var schemaHash = ResponsesToolSchemaHashes.Compute("""{"type":"object"}"""); + sessions.Seed(new LlmSessionSnapshot( + "resp_previous", + "user-1", + "user-1", + LlmSessionOriginKind.ApiKey, + null, + LlmSessionStatus.Completed, + DateTimeOffset.UtcNow.AddMinutes(-2), + TimeSpan.FromHours(1), + null, + "response-session:resp_previous", + 3, + "resp_previous:tool:call_1:cancelled", + [ + new LlmSessionForwardedToolCallSnapshot( + "call_1", + "get_weather", + schemaHash, + """{"city":"Singapore"}""", + LlmSessionForwardedToolCallStatus.Cancelled, + DateTimeOffset.UtcNow.AddMinutes(30), + null, + DateTimeOffset.UtcNow.AddHours(-1), + null, + null), + ])); + await using var app = await CreateAppAsync(provider, sessions); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses") + { + Content = JsonContent($$""" + { + "model": "gpt-5.4", + "previous_response_id": "resp_previous", + "input": [ + { + "type": "function_call_output", + "call_id": "call_1", + "schema_hash": "{{schemaHash}}", + "output": {"temperature": 28} + } + ] + } + """), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token"); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body); + GetErrorCode(body).Should().Be("tool_call_not_available"); + body.Should().NotContain("secret-token"); + sessions.ToolResults.Should().BeEmpty(); + sessions.ResolvedToolResults.Should().BeEmpty(); + sessions.Registered.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + [Fact] public async Task PostResponses_WithFunctionCallOutputSchemaMismatch_ShouldReturnBadRequest() { @@ -991,6 +1057,43 @@ public async Task PostResponses_WithExpiredPreviousResponse_ShouldRejectResume() provider.LastRequest.Should().BeNull(); } + [Fact] + public async Task PostResponses_WithCancelledPreviousResponse_ShouldReturnStructuredNotAvailableError_WithoutCallingProvider() + { + var provider = new RecordingLLMProvider(); + var sessions = new RecordingResponseSessionStore(); + sessions.Seed(new LlmSessionSnapshot( + "resp_previous", + "user-1", + "user-1", + LlmSessionOriginKind.ApiKey, + null, + LlmSessionStatus.Cancelled, + DateTimeOffset.UtcNow.AddMinutes(-10), + TimeSpan.FromHours(1), + null, + "response-session:resp_previous", + 2, + "resp_previous:status:cancelled")); + await using var app = await CreateAppAsync(provider, sessions); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses") + { + Content = JsonContent("""{"model":"gpt-5.4","input":"continue","previous_response_id":"resp_previous"}"""), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token"); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body); + GetErrorCode(body).Should().Be("previous_response_not_available"); + body.Should().NotContain("secret-token"); + sessions.Registered.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + [Fact] public async Task PostResponses_WithPreviousResponseFromDifferentScope_ShouldReturnForbidden() {