Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
174 changes: 164 additions & 10 deletions runtime/csharp/Prompty.Anthropic.Tests/AnthropicExecutorTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Text.Json;
using Prompty.Core;

namespace Prompty.Anthropic.Tests;
Expand Down Expand Up @@ -112,20 +113,30 @@ public void FormatToolMessages_BatchesToolResultsIntoSingleUserMessage()
new() { Id = "call_2", Name = "get_time", Arguments = """{"tz":"PST"}""" },
};
var toolResults = new List<string> { "72°F", "3:00 PM" };
using var rawResponse = JsonDocument.Parse(
"""
{
"content": [
{"type":"text","text":"Let me check."},
{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"Seattle"}},
{"type":"tool_use","id":"call_2","name":"get_time","input":{"tz":"PST"}}
]
}
""");

var messages = executor.FormatToolMessages("raw", toolCalls, toolResults, "Let me check.");
var messages = executor.FormatToolMessages(rawResponse.RootElement, toolCalls, toolResults, "Let me check.");

// Should be 2 messages: assistant + single user message (NOT 3)
Assert.Equal(2, messages.Count);

// Assistant message preserves text + tool_use content blocks
Assert.Equal(Role.Assistant, messages[0].Role);
Assert.Equal("Let me check.", messages[0].Text);
var content = Assert.IsType<List<Dictionary<string, object?>>>(messages[0].Metadata["content"]);
Assert.Equal(3, content.Count); // 1 text + 2 tool_use
Assert.Equal("text", content[0]["type"]);
Assert.Equal("tool_use", content[1]["type"]);
Assert.Equal("tool_use", content[2]["type"]);
var content = Assert.IsType<JsonElement>(messages[0].Metadata["content"]);
Assert.Equal(3, content.GetArrayLength()); // 1 text + 2 tool_use
Assert.Equal("text", content[0].GetProperty("type").GetString());
Assert.Equal("tool_use", content[1].GetProperty("type").GetString());
Assert.Equal("tool_use", content[2].GetProperty("type").GetString());

// Single user message with batched tool_result blocks
Assert.Equal(Role.User, messages[1].Role);
Expand All @@ -148,12 +159,155 @@ public void FormatToolMessages_NoTextContent_OmitsTextBlock()
new() { Id = "call_1", Name = "fn", Arguments = "{}" },
};
var toolResults = new List<string> { "result" };
using var rawResponse = JsonDocument.Parse(
"""{"content":[{"type":"tool_use","id":"call_1","name":"fn","input":{}}]}""");

var messages = executor.FormatToolMessages("raw", toolCalls, toolResults);
var messages = executor.FormatToolMessages(rawResponse.RootElement, toolCalls, toolResults);

var content = Assert.IsType<JsonElement>(messages[0].Metadata["content"]);
Assert.Single(content.EnumerateArray()); // Only tool_use, no text block
Assert.Equal("tool_use", content[0].GetProperty("type").GetString());
}

[Fact]
public void FormatToolMessages_RoundTripsCorrelatedBlocksIntoNextRequest()
{
var executor = new Anthropic.AnthropicExecutor();
var agent = TestHelpers.CreateAgent(provider: "anthropic");
using var rawResponse = JsonDocument.Parse(
"""
{
"content": [
{"type":"thinking","thinking":"I should look this up.","signature":"signed-thinking"},
{"type":"text","text":"Checking."},
{"type":"tool_use","id":"call_1","name":"lookup","input":{"key":"value"}}
]
}
""");
var messages = executor.FormatToolMessages(
rawResponse.RootElement,
[new ToolCall { Id = "call_1", Name = "lookup", Arguments = """{"key":"value"}""" }],
["result"],
"Checking.");

var body = executor.BuildRequestBody(agent, messages, stream: false);
var wireMessages = Assert.IsType<List<Dictionary<string, object?>>>(body["messages"]);

var assistantContent = Assert.IsType<JsonElement>(wireMessages[0]["content"]);
Assert.Equal("thinking", assistantContent[0].GetProperty("type").GetString());
Assert.Equal("signed-thinking", assistantContent[0].GetProperty("signature").GetString());
Assert.Equal("text", assistantContent[1].GetProperty("type").GetString());
Assert.Equal("tool_use", assistantContent[2].GetProperty("type").GetString());
Assert.Equal("call_1", assistantContent[2].GetProperty("id").GetString());

var userContent = Assert.IsType<List<Dictionary<string, object?>>>(wireMessages[1]["content"]);
Assert.Equal("tool_result", userContent[0]["type"]);
Assert.Equal("call_1", userContent[0]["tool_use_id"]);
Assert.Equal("result", userContent[0]["content"]);
}

[Fact]
public async Task FormatToolMessages_ReconstructsStreamingToolAndThinkingBlocks()
{
var executor = new Anthropic.AnthropicExecutor();
var stream = new PromptyStream(StreamEvents(
"""{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}""",
"""{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"I should look."}}""",
"""{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"signed"}}""",
"""{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}""",
"""{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Checking "}}""",
"""{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"now."}}""",
"""{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"call_1","name":"lookup","input":{}}}""",
"""{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"key\":\"value\"}"}}"""));
await foreach (var _ in stream)
{
}
Comment on lines +223 to +224

var messages = executor.FormatToolMessages(
stream,
[new ToolCall { Id = "call_1", Name = "lookup", Arguments = """{"key":"value"}""" }],
["result"]);
var content = Assert.IsType<List<Dictionary<string, object?>>>(messages[0].Metadata["content"]);
Assert.Single(content); // Only tool_use, no text block
Assert.Equal("tool_use", content[0]["type"]);

Assert.Equal("thinking", content[0]["type"]?.ToString());
Assert.Equal("I should look.", content[0]["thinking"]);
Assert.Equal("signed", content[0]["signature"]);
Assert.Equal("text", content[1]["type"]?.ToString());
Assert.Equal("Checking now.", content[1]["text"]);
Assert.Equal("tool_use", content[2]["type"]?.ToString());
Assert.Equal("call_1", content[2]["id"]?.ToString());
Assert.Equal("""{"key":"value"}""", Assert.IsType<JsonElement>(content[2]["input"]).GetRawText());
}
}

[Fact]
public async Task FormatToolMessages_PreservesValidInitialInputWhenStreamEndsMidJson()
{
var executor = new Anthropic.AnthropicExecutor();
var stream = new PromptyStream(StreamEvents(
"""{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_1","name":"lookup","input":{}}}""",
"""{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"key\":"}}"""));
await foreach (var _ in stream)
{
}
Comment on lines +250 to +251

var messages = executor.FormatToolMessages(
stream,
[new ToolCall { Id = "call_1", Name = "lookup", Arguments = """{"key":""" }],
["invalid arguments"]);
var content = Assert.IsType<List<Dictionary<string, object?>>>(messages[0].Metadata["content"]);

Assert.Equal("{}", Assert.IsType<JsonElement>(content[0]["input"]).GetRawText());
}

[Fact]
public async Task FormatToolMessages_SynthesizesBlocksWhenStreamItemsAreUnavailable()
{
var executor = new Anthropic.AnthropicExecutor();
var stream = new PromptyStream(StreamEvents());
await foreach (var _ in stream)
{
}
Comment on lines +268 to +269

var messages = executor.FormatToolMessages(
stream,
[new ToolCall { Id = "call_1", Name = "lookup", Arguments = """{"key":"value"}""" }],
["result"],
"Checking.");
var content = Assert.IsType<List<Dictionary<string, object?>>>(messages[0].Metadata["content"]);

Assert.Equal("text", content[0]["type"]);
Assert.Equal("Checking.", content[0]["text"]);
Assert.Equal("tool_use", content[1]["type"]);
Assert.Equal("call_1", content[1]["id"]);
Assert.Equal("""{"key":"value"}""", Assert.IsType<JsonElement>(content[1]["input"]).GetRawText());
}

[Fact]
public void BuildRequestBody_SingleToolResultMetadata_PreservesCorrelation()
{
var executor = new Anthropic.AnthropicExecutor();
var agent = TestHelpers.CreateAgent(provider: "anthropic");
var message = new Message
{
Role = Role.Tool,
Parts = [new TextPart { Value = "result" }],
Metadata = new Dictionary<string, object?> { ["tool_use_id"] = "call_2" },
};

var body = executor.BuildRequestBody(agent, [message], stream: false);
var wireMessages = Assert.IsType<List<Dictionary<string, object?>>>(body["messages"]);
var content = Assert.IsType<List<Dictionary<string, object?>>>(wireMessages[0]["content"]);

Assert.Equal("call_2", content[0]["tool_use_id"]);
}

private static async IAsyncEnumerable<object> StreamEvents(params string[] events)
{
foreach (var json in events)
{
await Task.Yield();
using var document = JsonDocument.Parse(json);
yield return document.RootElement.Clone();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Net;
using System.Net.Sockets;
using System.Text;
using Prompty.Core;

namespace Prompty.Anthropic.Tests;

/// <summary>
/// Tests Anthropic model discovery protocol and pagination behavior.
/// </summary>
public class AnthropicModelDiscoveryTests
{
[Fact]
public void ModelLister_ImplementsGeneratedProtocol()
{
Assert.IsAssignableFrom<IModelLister>(new AnthropicModelLister());
}

[Fact]
public async Task ListModelsAsync_FollowsPaginationAndPreservesProviderPayload()
{
using var socket = new TcpListener(IPAddress.Loopback, 0);
socket.Start();
var port = ((IPEndPoint)socket.LocalEndpoint).Port;
socket.Stop();

using var listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{port}/");
listener.Start();
var requests = new List<(string Query, string? ApiKey, string? Version)>();
var server = Task.Run(async () =>
{
for (var page = 0; page < 2; page++)
{
var context = await listener.GetContextAsync();
requests.Add((
context.Request.Url?.Query ?? string.Empty,
context.Request.Headers["x-api-key"],
context.Request.Headers["anthropic-version"]));
var body = page == 0
? """
{
"data": [{"id":"claude-first","display_name":"Claude First","type":"model"}],
"has_more": true,
"last_id": "claude-first"
}
"""
: """
{
"data": [{"id":"claude-second","created_at":"2025-01-01T00:00:00Z","type":"model"}],
"has_more": false,
"last_id": "claude-second"
}
""";
var bytes = Encoding.UTF8.GetBytes(body);
context.Response.ContentType = "application/json";
context.Response.ContentLength64 = bytes.Length;
await context.Response.OutputStream.WriteAsync(bytes);
context.Response.Close();
}
});

var models = await AnthropicModels.ListModelsAsync(
new ApiKeyConnection
{
Endpoint = $"http://localhost:{port}",
ApiKey = "test-anthropic-key",
});

await server;
Assert.Equal(2, models.Count);
Assert.Equal("Claude First", models[0].DisplayName);
Assert.Equal("2025-01-01T00:00:00Z", models[1].AdditionalProperties!["created_at"]?.ToString());
Assert.Equal("?limit=100", requests[0].Query);
Assert.Equal("?limit=100&after_id=claude-first", requests[1].Query);
Assert.All(requests, request =>
{
Assert.Equal("test-anthropic-key", request.ApiKey);
Assert.Equal("2023-06-01", request.Version);
});
}
}
4 changes: 2 additions & 2 deletions runtime/csharp/Prompty.Anthropic.Tests/TestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ internal static Message CreateAssistantWithToolCalls(string text, List<ToolCall>
{
Role = Role.Assistant,
Parts = [new TextPart { Value = text }],
Metadata = new Dictionary<string, object>
Metadata = new Dictionary<string, object?>
{
["tool_calls"] = toolCalls,
},
Expand All @@ -113,7 +113,7 @@ internal static Message CreateToolMessage(string toolCallId, string content)
{
Role = Role.Tool,
Parts = [new TextPart { Value = content }],
Metadata = new Dictionary<string, object>
Metadata = new Dictionary<string, object?>
{
["tool_call_id"] = toolCallId,
},
Expand Down
Loading
Loading