Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions test/Aevatar.Hosting.Tests/MainnetMessagesEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -823,6 +867,8 @@ private sealed class MessagesRecordingSessionStore :
public List<LlmSessionRecord> 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<LlmSessionRegistrationResult> RegisterAsync(
LlmSessionRecord record,
Expand Down Expand Up @@ -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<LlmSessionSnapshot?> GetByResponseIdAsync(
string responseId,
Expand Down
197 changes: 194 additions & 3 deletions test/Aevatar.Hosting.Tests/MainnetResponsesEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -2283,6 +2468,12 @@ private static async Task<WebApplication> 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";
Expand Down