diff --git a/test/Aevatar.Hosting.Tests/MainnetMessagesEndpointsTests.cs b/test/Aevatar.Hosting.Tests/MainnetMessagesEndpointsTests.cs index e865540db..0e98240b4 100644 --- a/test/Aevatar.Hosting.Tests/MainnetMessagesEndpointsTests.cs +++ b/test/Aevatar.Hosting.Tests/MainnetMessagesEndpointsTests.cs @@ -266,6 +266,46 @@ public async Task PostMessages_WithNonBearerAuthorization_ShouldReturn401() provider.LastRequest.Should().BeNull(); } + [Fact] + public async Task PostMessages_WithInvalidToolResultLocation_ShouldReturnStructuredErrorWithoutRegisteringSession() + { + var provider = new MessagesRecordingLLMProvider(); + var sessions = new MessagesRecordingSessionStore(); + await using var app = await CreateAppAsync(provider, sessions); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/messages") + { + Content = JsonContent(""" + { + "model": "claude-haiku-4-5", + "max_tokens": 64, + "messages": [ + {"role": "assistant", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_secret", "content": "secret-output"} + ]} + ] + } + """), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "messages-secret-token"); + + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, body); + using var doc = JsonDocument.Parse(body); + doc.RootElement.GetProperty("type").GetString().Should().Be("error"); + doc.RootElement.GetProperty("error").GetProperty("type").GetString().Should().Be("invalid_messages"); + body.Should().NotContain("messages-secret-token"); + body.Should().NotContain("secret-output"); + sessions.Registered.Should().BeEmpty(); + sessions.StatusUpdates.Should().BeEmpty(); + sessions.ToolResults.Should().BeEmpty(); + sessions.ResolvedToolResults.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + [Fact] public async Task PostMessages_WithToolResultBlockInUserContent_ShouldFlattenIntoToolRoleMessage() { @@ -316,6 +356,10 @@ public async Task PostMessages_WithToolResultBlockInUserContent_ShouldFlattenInt messages[2].Role.Should().Be("tool"); messages[2].ToolCallId.Should().Be("toolu_x"); messages[2].Content.Should().Be("sunny"); + sessions.Registered.Should().ContainSingle(); + sessions.Registered[0].PreviousResponseId.Should().BeNullOrEmpty(); + sessions.ToolResults.Should().BeEmpty(); + sessions.ResolvedToolResults.Should().BeEmpty(); } [Fact] @@ -823,6 +867,8 @@ private sealed class MessagesRecordingSessionStore : public List Registered { get; } = []; public List<(string ActorId, string ResponseId, LlmSessionStatus Status)> StatusUpdates { get; } = []; public List<(string ActorId, string ResponseId, LlmSessionCompletion Completion)> RecordedCompletions { get; } = []; + public List<(string ActorId, string ResponseId, string CallId, string SchemaHash, string ResultJson)> ToolResults { get; } = []; + public List<(string ActorId, string ResponseId, string CallId)> ResolvedToolResults { get; } = []; public Task RegisterAsync( LlmSessionRecord record, @@ -909,13 +955,21 @@ public Task ReceiveForwardedToolResultAsync( string callId, string schemaHash, string resultJson, - CancellationToken ct = default) => Task.CompletedTask; + CancellationToken ct = default) + { + ToolResults.Add((sessionActorId, responseId, callId, schemaHash, resultJson)); + return Task.CompletedTask; + } public Task ResolveForwardedToolResultAsync( string sessionActorId, string responseId, string callId, - CancellationToken ct = default) => Task.CompletedTask; + CancellationToken ct = default) + { + ResolvedToolResults.Add((sessionActorId, responseId, callId)); + return Task.CompletedTask; + } public Task GetByResponseIdAsync( string responseId, diff --git a/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs b/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs index 50ba84e0c..4f3e965cc 100644 --- a/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs +++ b/test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs @@ -850,6 +850,73 @@ public async Task PostResponses_WithFunctionCallOutputSchemaMismatch_ShouldRetur provider.LastRequest.Should().BeNull(); } + [Fact] + public async Task PostResponses_WithFunctionCallOutputForUnknownCall_ShouldReturnStructuredNotFoundWithoutPersistingResult() + { + 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(-1), + TimeSpan.FromHours(1), + null, + "response-session:resp_previous", + 2, + "resp_previous:tool:call_1:emitted", + [ + new LlmSessionForwardedToolCallSnapshot( + "call_1", + "get_weather", + schemaHash, + """{"city":"Singapore"}""", + LlmSessionForwardedToolCallStatus.Pending, + DateTimeOffset.UtcNow.AddHours(1), + null, + DateTimeOffset.UtcNow.AddMinutes(-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_missing", + "schema_hash": "{{schemaHash}}", + "output": {"secret": "request-secret"} + } + ] + } + """), + }; + 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); + GetResponseErrorCode(body).Should().Be("tool_call_not_found"); + body.Should().NotContain("secret-token"); + body.Should().NotContain("request-secret"); + sessions.ToolResults.Should().BeEmpty(); + sessions.ResolvedToolResults.Should().BeEmpty(); + sessions.Registered.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + [Fact] public async Task PostResponses_WithPreviousResponseId_ShouldRegisterLinkedSession() { @@ -930,7 +997,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"); + GetResponseErrorCode(body).Should().Be("previous_response_expired"); sessions.Registered.Should().BeEmpty(); provider.LastRequest.Should().BeNull(); } @@ -966,7 +1033,57 @@ 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"); + GetResponseErrorCode(body).Should().Be("response_scope_mismatch"); + sessions.Registered.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + + [Fact] + public async Task PostResponses_WithPreviousResponseAfterOwnerSubjectChanges_ShouldReturnStructuredForbiddenWithoutRegisteringSession() + { + var provider = new RecordingLLMProvider(); + var sessions = new RecordingResponseSessionStore(); + sessions.Seed(new LlmSessionSnapshot( + "resp_previous", + "scope-1", + "owner-before", + LlmSessionOriginKind.ApiKey, + null, + LlmSessionStatus.Completed, + DateTimeOffset.UtcNow.AddMinutes(-1), + TimeSpan.FromHours(1), + null, + "response-session:resp_previous", + 1, + "resp_previous:registered")); + await using var app = await CreateAppAsync( + provider, + sessions, + callerScopeResolver: new StubResponsesCallerScopeResolver( + scopeId: "scope-1", + ownerSubject: "owner-after", + originKind: LlmSessionOriginKind.ApiKey)); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses") + { + Content = JsonContent(""" + { + "model": "gpt-5.4", + "input": "continue with caller-scope-secret", + "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.Forbidden, body); + GetResponseErrorCode(body).Should().Be("response_scope_mismatch"); + body.Should().NotContain("secret-token"); + body.Should().NotContain("caller-scope-secret"); sessions.Registered.Should().BeEmpty(); provider.LastRequest.Should().BeNull(); } @@ -1002,11 +1119,79 @@ 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"); + GetResponseErrorCode(body).Should().Be("response_origin_mismatch"); sessions.Registered.Should().BeEmpty(); provider.LastRequest.Should().BeNull(); } + [Fact] + public async Task PostResponsesCancel_WithResponseFromDifferentScope_ShouldReturnStructuredForbiddenWithoutStatusUpdate() + { + var provider = new RecordingLLMProvider(); + var sessions = new RecordingResponseSessionStore(); + sessions.Seed(new LlmSessionSnapshot( + "resp_foreign", + "other-user", + "other-user", + LlmSessionOriginKind.ApiKey, + null, + LlmSessionStatus.Completed, + DateTimeOffset.UtcNow.AddMinutes(-1), + TimeSpan.FromHours(1), + null, + "response-session:resp_foreign", + 1, + "resp_foreign:registered")); + await using var app = await CreateAppAsync(provider, sessions); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses/resp_foreign/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.Forbidden, body); + GetResponseErrorCode(body).Should().Be("response_scope_mismatch"); + body.Should().NotContain("secret-token"); + sessions.StatusUpdates.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + + [Fact] + public async Task PostResponsesCancel_WithResponseFromDifferentOrigin_ShouldReturnStructuredForbiddenWithoutStatusUpdate() + { + var provider = new RecordingLLMProvider(); + var sessions = new RecordingResponseSessionStore(); + sessions.Seed(new LlmSessionSnapshot( + "resp_channel", + "user-1", + "user-1", + LlmSessionOriginKind.Channel, + null, + LlmSessionStatus.Completed, + DateTimeOffset.UtcNow.AddMinutes(-1), + TimeSpan.FromHours(1), + null, + "response-session:resp_channel", + 1, + "resp_channel:registered")); + await using var app = await CreateAppAsync(provider, sessions); + var client = app.GetTestClient(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses/resp_channel/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.Forbidden, body); + GetResponseErrorCode(body).Should().Be("response_origin_mismatch"); + body.Should().NotContain("secret-token"); + sessions.StatusUpdates.Should().BeEmpty(); + provider.LastRequest.Should().BeNull(); + } + [Fact] public async Task PostResponsesCancel_ShouldMarkResponseAndPendingToolCallsCancelled() { @@ -2283,6 +2468,12 @@ private static async Task CreateAppAsync( private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + private static string? GetResponseErrorCode(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";