Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
12 changes: 11 additions & 1 deletion runtime/csharp/Prompty.Anthropic.Tests/AnthropicExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ namespace Prompty.Anthropic.Tests;
/// </summary>
public class AnthropicExecutorTests
{
[Fact]
public async Task ExecuteAsync_Cancelled_ThrowsBeforeConnectionValidation()
{
var executor = new Anthropic.AnthropicExecutor();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();

await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => executor.ExecuteAsync(new Core.Prompty(), [], cancellation.Token));
}

[Fact]
public async Task ExecuteAsync_MissingApiKey_ThrowsInvalidOperationException()
{
Expand Down Expand Up @@ -156,4 +167,3 @@ public void FormatToolMessages_NoTextContent_OmitsTextBlock()
Assert.Equal("tool_use", content[0]["type"]);
}
}

26 changes: 18 additions & 8 deletions runtime/csharp/Prompty.Anthropic/AnthropicExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,40 @@ public class AnthropicExecutor : IExecutor
private const string ApiVersion = "2023-06-01";
private const int DefaultMaxTokens = 4096;

public async Task<object> ExecuteAsync(Core.Prompty agent, List<Message> messages)
public async Task<object> ExecuteAsync(
Core.Prompty agent,
List<Message> messages,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var streaming = agent.Metadata?.TryGetValue("stream", out var streamVal) == true && streamVal is true;

if (streaming)
return ExecuteStreamAsync(agent, messages);
return ExecuteStreamAsync(agent, messages, cancellationToken);

return await ExecuteNonStreamAsync(agent, messages);
return await ExecuteNonStreamAsync(agent, messages, cancellationToken);
Comment on lines +22 to +33
}

private async Task<object> ExecuteNonStreamAsync(Core.Prompty agent, List<Message> messages)
private async Task<object> ExecuteNonStreamAsync(
Core.Prompty agent,
List<Message> messages,
CancellationToken cancellationToken)
{
var body = BuildRequestBody(agent, messages, stream: false);
var (endpoint, apiKey) = GetConnectionInfo(agent);

var request = CreateRequest(endpoint, apiKey, body);
var response = await _httpClient.SendAsync(request);
var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();

var json = await response.Content.ReadFromJsonAsync<JsonElement>();
var json = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
return json;
}

private PromptyStream ExecuteStreamAsync(Core.Prompty agent, List<Message> messages)
private PromptyStream ExecuteStreamAsync(
Core.Prompty agent,
List<Message> messages,
CancellationToken cancellationToken)
{
var body = BuildRequestBody(agent, messages, stream: true);
var (endpoint, apiKey) = GetConnectionInfo(agent);
Expand All @@ -69,7 +79,7 @@ async IAsyncEnumerable<object> StreamEvents([System.Runtime.CompilerServices.Enu
}
}

return new PromptyStream(StreamEvents());
return new PromptyStream(StreamEvents(cancellationToken));
}

internal Dictionary<string, object?> BuildRequestBody(Core.Prompty agent, List<Message> messages, bool stream)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ public Task<List<Message>> ParseAsync(Prompty agent, string rendered, Dictionary

public void EnqueueResponse(object response) => _responses.Enqueue(response);

public Task<object> ExecuteAsync(Prompty agent, List<Message> messages)
public Task<object> ExecuteAsync(
Prompty agent,
List<Message> messages,
CancellationToken cancellationToken = default)
{
// Snapshot the messages at call time
Calls.Add(new List<Message>(messages));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.

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

namespace Prompty.Core.Tests;

public class ConnectionRoundtripVectorTests
{
private static readonly string VectorsPath = FindVectorsPath();

[Theory]
[InlineData("known_reference_connection_roundtrip_unchanged")]
[InlineData("unknown_connection_kind_preserves_payload")]
[InlineData("unknown_connection_case_collision_preserves_payload")]
public void ConnectionRoundtripVectors_PreserveExactDiscriminatorAndPayload(string vectorName)
{
using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath));
var vector = document.RootElement
.GetProperty("vectors")
.EnumerateArray()
.Single(candidate => candidate.GetProperty("name").GetString() == vectorName);

var input = vector.GetProperty("input");
var expected = vector.GetProperty("expected");
var expectedKind = expected.GetProperty("kind").GetString()!;
var data = JsonSerializer.Deserialize<Dictionary<string, object?>>(input.GetRawText())!;

var loaded = Connection.Load(data);
if (expectedKind == "reference")
Assert.IsType<ReferenceConnection>(loaded);
else
Assert.IsNotType<ReferenceConnection>(loaded);

var saved = loaded.Save();
Assert.Equal(expectedKind, saved["kind"]);
AssertJsonEqual(vectorName, "save", expected, saved);

var reloaded = Connection.Load(saved);
var resaved = reloaded.Save();
Assert.Equal(expectedKind, resaved["kind"]);
AssertJsonEqual(vectorName, "reload", expected, resaved);
}

private static void AssertJsonEqual(
string vectorName,
string operation,
JsonElement expected,
Dictionary<string, object?> actual)
{
var expectedNode = JsonNode.Parse(expected.GetRawText());
var actualNode = JsonNode.Parse(JsonSerializer.Serialize(actual));
Assert.True(
JsonNode.DeepEquals(expectedNode, actualNode),
$"[{vectorName}] {operation} changed the Connection payload.\nExpected: {expectedNode}\nActual: {actualNode}");
}

private static string FindVectorsPath()
{
var directory = AppContext.BaseDirectory;
for (var i = 0; i < 10; i++)
{
var candidate = Path.Combine(
directory,
"spec",
"vectors",
"model",
"connection_roundtrip_vectors.json");
if (File.Exists(candidate))
return candidate;

directory = Path.GetDirectoryName(directory) ?? directory;
}

throw new FileNotFoundException("Could not locate the shared Connection roundtrip vectors.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.

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

namespace Prompty.Core.Tests;

public class ContentPartDiscriminatorVectorTests
{
private static readonly string VectorsPath = FindVectorsPath();

[Theory]
[InlineData("known_text_content_part_loads")]
[InlineData("unknown_content_part_kind_is_rejected")]
[InlineData("content_part_case_collision_is_rejected")]
public void ContentPartDiscriminatorVectors_EnforceClosedCaseSensitiveKinds(string vectorName)
{
using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath));
var vector = document.RootElement
.GetProperty("vectors")
.EnumerateArray()
.Single(candidate => candidate.GetProperty("name").GetString() == vectorName);
var input = vector.GetProperty("input");
var expected = vector.GetProperty("expected");
var data = JsonSerializer.Deserialize<Dictionary<string, object?>>(input.GetRawText())!;

switch (vector.GetProperty("operation").GetString())
{
case "load":
var loaded = ContentPart.Load(data);
Assert.IsType<TextPart>(loaded);
AssertJsonEqual(vectorName, expected, loaded.Save());
break;
case "load-error":
var exception = Assert.ThrowsAny<ArgumentException>(() => ContentPart.Load(data));
Assert.Contains(expected.GetProperty("discriminator").GetString()!, exception.Message);
Assert.Contains(expected.GetProperty("value").GetString()!, exception.Message);
break;
default:
throw new InvalidOperationException($"[{vectorName}] unsupported vector operation.");
}
}

private static void AssertJsonEqual(
string vectorName,
JsonElement expected,
Dictionary<string, object?> actual)
{
var expectedNode = JsonNode.Parse(expected.GetRawText());
var actualNode = JsonNode.Parse(JsonSerializer.Serialize(actual));
Assert.True(
JsonNode.DeepEquals(expectedNode, actualNode),
$"[{vectorName}] load/save changed the ContentPart payload.\nExpected: {expectedNode}\nActual: {actualNode}");
}

private static string FindVectorsPath()
{
var directory = AppContext.BaseDirectory;
for (var i = 0; i < 10; i++)
{
var candidate = Path.Combine(
directory,
"spec",
"vectors",
"model",
"content_part_discriminator_vectors.json");
if (File.Exists(candidate))
return candidate;

directory = Path.GetDirectoryName(directory) ?? directory;
}

throw new FileNotFoundException("Could not locate the shared ContentPart discriminator vectors.");
}
}
33 changes: 30 additions & 3 deletions runtime/csharp/Prompty.Core.Tests/PipelineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,22 @@ public async Task ExecuteAsync_UsesProvider()
Assert.Equal("mock-response", response);
}

[Fact]
public async Task ExecuteAsync_ForwardsCancellationToken()
{
var agent = CreateAgent();
var executor = new MockExecutor();
InvokerRegistry.RegisterExecutor("openai", executor);
using var cancellation = new CancellationTokenSource();

await Pipeline.ExecuteAsync(
agent,
[new Message { Parts = [new TextPart { Value = "Hi" }] }],
cancellation.Token);

Assert.Equal(cancellation.Token, executor.LastCancellationToken);
}

[Fact]
public async Task ProcessAsync_UsesProvider()
{
Expand Down Expand Up @@ -601,8 +617,16 @@ public Task<List<Message>> ParseAsync(Prompty agent, string rendered, Dictionary

internal class MockExecutor : IExecutor
{
public Task<object> ExecuteAsync(Prompty agent, List<Message> messages)
=> Task.FromResult<object>("mock-response");
public CancellationToken LastCancellationToken { get; private set; }

public Task<object> ExecuteAsync(
Prompty agent,
List<Message> messages,
CancellationToken cancellationToken = default)
{
LastCancellationToken = cancellationToken;
return Task.FromResult<object>("mock-response");
}

public List<Message> FormatToolMessages(object rawResponse, List<ToolCall> toolCalls, List<string> toolResults, string? textContent = null)
{
Expand All @@ -629,7 +653,10 @@ internal class ToolCallingExecutor : IExecutor
{
private int _callCount;

public Task<object> ExecuteAsync(Prompty agent, List<Message> messages)
public Task<object> ExecuteAsync(
Prompty agent,
List<Message> messages,
CancellationToken cancellationToken = default)
{
_callCount++;
if (_callCount == 1)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.

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

namespace Prompty.Core.Tests;

public class PropertyScalarCoercionVectorTests
{
private static readonly string VectorsPath = FindVectorsPath();

[Fact]
public void AllPrimitivePropertyScalarsCoerceAtomically()
{
using var document = JsonDocument.Parse(File.ReadAllText(VectorsPath));
var vectors = document.RootElement.GetProperty("vectors").EnumerateArray().ToArray();
Assert.Single(vectors);

var vector = vectors[0];
Assert.Equal("all_primitive_property_scalars_coerce_atomically", vector.GetProperty("name").GetString());
Assert.Equal("load", vector.GetProperty("operation").GetString());

var cases = vector.GetProperty("cases").EnumerateArray().ToArray();
Assert.Equal(
["string", "integer", "float", "boolean"],
cases.Select(candidate => candidate.GetProperty("name").GetString()!).ToArray());

foreach (var scalarCase in cases)
{
var name = scalarCase.GetProperty("name").GetString();
var expected = scalarCase.GetProperty("expected");
var loaded = Property.FromJson(scalarCase.GetProperty("input").GetRawText());

Assert.Equal(expected.GetProperty("kind").GetString(), loaded.Kind);
Assert.True(
JsonNode.DeepEquals(
JsonNode.Parse(expected.GetProperty("example").GetRawText()),
JsonNode.Parse(JsonSerializer.Serialize(loaded.Example))),
$"[{name}] changed or dropped the Property example.");
}
}

private static string FindVectorsPath()
{
var directory = AppContext.BaseDirectory;
for (var i = 0; i < 10; i++)
{
var candidate = Path.Combine(
directory,
"spec",
"vectors",
"model",
"property_scalar_coercion_vectors.json");
if (File.Exists(candidate))
return candidate;

directory = Path.GetDirectoryName(directory) ?? directory;
}

throw new FileNotFoundException("Could not locate the shared Property scalar coercion vectors.");
}
}
Loading
Loading