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
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ private static ChatResponseUpdate ValidateAndEmitRunFinished(string? conversatio
{
ConversationId = conversationId,
ResponseId = responseId,
FinishReason = !string.IsNullOrEmpty(runFinished.FinishReason)
? new ChatFinishReason(runFinished.FinishReason)
: null,
CreatedAt = DateTimeOffset.UtcNow
};
}
Expand Down Expand Up @@ -341,8 +344,13 @@ public static async IAsyncEnumerable<BaseEvent> AsAGUIEventStreamAsync(
};

string? currentMessageId = null;
ChatFinishReason? lastFinishReason = null;
await foreach (var chatResponse in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (chatResponse.FinishReason is not null)
{
lastFinishReason = chatResponse.FinishReason;
}
if (chatResponse is { Contents.Count: > 0 } &&
chatResponse.Contents[0] is TextContent &&
!string.Equals(currentMessageId, chatResponse.MessageId, StringComparison.Ordinal))
Expand Down Expand Up @@ -480,6 +488,7 @@ chatResponse.Contents[0] is TextContent &&
{
ThreadId = threadId,
RunId = runId,
FinishReason = lastFinishReason?.Value,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public RunFinishedEvent()
[JsonPropertyName("runId")]
public string RunId { get; set; } = string.Empty;

[JsonPropertyName("finishReason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? FinishReason { get; set; }

[JsonPropertyName("result")]
public JsonElement? Result { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,100 @@ public async Task AsChatResponseUpdatesAsync_ConvertsRunFinishedEvent_ToResponse
Assert.Equal("thread1", updates[1].ConversationId);
}

[Fact]
public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithFinishReasonStop_MapsToFinishReasonAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = "stop" }
];

// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}

// Assert
Assert.Equal(2, updates.Count);
ChatResponseUpdate finishUpdate = updates[1];
Assert.NotNull(finishUpdate.FinishReason);
Assert.Equal(ChatFinishReason.Stop.Value, finishUpdate.FinishReason.Value.Value);
}

[Fact]
public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithFinishReasonToolCalls_MapsToFinishReasonAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = "tool_calls" }
];

// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}

// Assert
Assert.Equal(2, updates.Count);
ChatResponseUpdate finishUpdate = updates[1];
Assert.NotNull(finishUpdate.FinishReason);
Assert.Equal(ChatFinishReason.ToolCalls.Value, finishUpdate.FinishReason.Value.Value);
}

[Fact]
public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithNullFinishReason_MapsToNullFinishReasonAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = null }
];

// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}

// Assert
Assert.Equal(2, updates.Count);
ChatResponseUpdate finishUpdate = updates[1];
Assert.Null(finishUpdate.FinishReason);
}

[Fact]
public async Task AsChatResponseUpdatesAsync_RunFinishedEvent_WithEmptyFinishReason_MapsToNullFinishReasonAsync()
{
// Arrange
List<BaseEvent> events =
[
new RunStartedEvent { ThreadId = "thread1", RunId = "run1" },
new RunFinishedEvent { ThreadId = "thread1", RunId = "run1", FinishReason = string.Empty }
];

// Act
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in events.ToAsyncEnumerableAsync().AsChatResponseUpdatesAsync(AGUIJsonSerializerContext.Default.Options))
{
updates.Add(update);
}

// Assert
Assert.Equal(2, updates.Count);
ChatResponseUpdate finishUpdate = updates[1];
Assert.Null(finishUpdate.FinishReason);
}

[Fact]
public async Task AsChatResponseUpdatesAsync_ConvertsRunErrorEvent_ToErrorContentAsync()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,148 @@ public async Task AsAGUIEventStreamAsync_WithMixedContentTypes_EmitsAllEventType
Assert.Contains(events, e => e is ToolCallEndEvent);
Assert.Contains(events, e => e is RunFinishedEvent);
}

[Fact]
public async Task AsAGUIEventStreamAsync_RunFinishedEvent_ContainsFinishReason_StopAsync()
{
// Arrange — Simulate an agent run that completes normally with FinishReason.Stop
const string ThreadId = "thread1";
const string RunId = "run1";
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Hello world") { MessageId = "msg1" },
new ChatResponseUpdate(ChatRole.Assistant, "!") { MessageId = "msg1", FinishReason = ChatFinishReason.Stop }
];

// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None))
{
events.Add(evt);
}

// Assert
RunFinishedEvent finishEvent = Assert.IsType<RunFinishedEvent>(events.Last());
Assert.Equal(ChatFinishReason.Stop.Value, finishEvent.FinishReason);
}

[Fact]
public async Task AsAGUIEventStreamAsync_RunFinishedEvent_ContainsFinishReason_ToolCallsAsync()
{
// Arrange — Simulate an agent run that ends with FinishReason.ToolCalls (client-side tool call)
const string ThreadId = "thread1";
const string RunId = "run1";
FunctionCallContent functionCall = new("call_1", "GetWeather", new Dictionary<string, object?> { ["city"] = "Seattle" });
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, [functionCall])
{
MessageId = "msg1",
FinishReason = ChatFinishReason.ToolCalls
}
];

// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None))
{
events.Add(evt);
}

// Assert
RunFinishedEvent finishEvent = Assert.IsType<RunFinishedEvent>(events.Last());
Assert.Equal(ChatFinishReason.ToolCalls.Value, finishEvent.FinishReason);
}

[Fact]
public async Task AsAGUIEventStreamAsync_RunFinishedEvent_FinishReasonNull_WhenNoUpdatesAsync()
{
// Arrange — No ChatResponseUpdates emitted (empty stream)
const string ThreadId = "thread1";
const string RunId = "run1";
List<ChatResponseUpdate> updates = [];

// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None))
{
events.Add(evt);
}

// Assert
RunFinishedEvent finishEvent = Assert.IsType<RunFinishedEvent>(events.Last());
Assert.Null(finishEvent.FinishReason);
}

[Fact]
public async Task AsAGUIEventStreamAsync_RunFinishedEvent_CapturesLastFinishReasonAsync()
{
// Arrange — Multiple updates, only the last one has FinishReason set.
// Verifies that FinishReason is captured from the last update that has it.
const string ThreadId = "thread1";
const string RunId = "run1";
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Part 1") { MessageId = "msg1" },
new ChatResponseUpdate(ChatRole.Assistant, "Part 2") { MessageId = "msg1" },
new ChatResponseUpdate(ChatRole.Assistant, "Part 3") { MessageId = "msg1", FinishReason = ChatFinishReason.Stop }
];

// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None))
{
events.Add(evt);
}

// Assert
RunFinishedEvent finishEvent = Assert.IsType<RunFinishedEvent>(events.Last());
Assert.Equal(ChatFinishReason.Stop.Value, finishEvent.FinishReason);
}

[Fact]
public async Task AsAGUIEventStreamAsync_RunFinishedEvent_FinishReason_SerializedCorrectlyAsync()
{
// Arrange — Verify the FinishReason is serialized in the JSON output per AG-UI spec
const string ThreadId = "thread1";
const string RunId = "run1";
List<ChatResponseUpdate> updates =
[
new ChatResponseUpdate(ChatRole.Assistant, "Done") { MessageId = "msg1", FinishReason = ChatFinishReason.Stop }
];

// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None))
{
events.Add(evt);
}

// Assert — Serialize the RunFinishedEvent and verify finishReason is present in JSON
RunFinishedEvent finishEvent = Assert.IsType<RunFinishedEvent>(events.Last());
string json = System.Text.Json.JsonSerializer.Serialize(finishEvent, AGUIJsonSerializerContext.Default.Options.GetTypeInfo(typeof(RunFinishedEvent)));
Assert.Contains("\"finishReason\"", json);
Assert.Contains("\"stop\"", json);
}

[Fact]
public async Task AsAGUIEventStreamAsync_RunFinishedEvent_FinishReasonNull_OmittedFromSerializationAsync()
{
// Arrange — When FinishReason is null, it should be omitted from JSON serialization
const string ThreadId = "thread1";
const string RunId = "run1";
List<ChatResponseUpdate> updates = [];

// Act
List<BaseEvent> events = [];
await foreach (BaseEvent evt in updates.ToAsyncEnumerableAsync().AsAGUIEventStreamAsync(ThreadId, RunId, AGUIJsonSerializerContext.Default.Options, CancellationToken.None))
{
events.Add(evt);
}

// Assert — Serialize the RunFinishedEvent and verify finishReason is NOT present in JSON
RunFinishedEvent finishEvent = Assert.IsType<RunFinishedEvent>(events.Last());
string json = System.Text.Json.JsonSerializer.Serialize(finishEvent, AGUIJsonSerializerContext.Default.Options.GetTypeInfo(typeof(RunFinishedEvent)));
Assert.DoesNotContain("\"finishReason\"", json);
}
}