From fa531576ed4ff41659d0dcbf158fb58fbfa6846d Mon Sep 17 00:00:00 2001 From: zxyao145 Date: Thu, 17 Sep 2026 23:30:01 +0800 Subject: [PATCH] feat(maf): emit final response as result update Surface the last agent message as a "result" AgentResponseUpdate right before the terminal event, and include it as a chat message in AgentResponse. On failures (turn.failed / thread error) the result carries the error message instead. Extend FakeCodexCli with failure injection and add tests covering the success, turn failure and thread error paths. --- src/CodexSdk.MAF/CodexAIAgent.cs | 38 +++++- .../Internal/ThreadEventExtensions.cs | 16 +++ .../CodexAIAgentResultUpdateTests.cs | 124 ++++++++++++++++++ .../ThreadEventExtensionsTests.cs | 12 ++ tests/FakeCodexCli/Program.cs | 13 +- tests/Support/FakeCodexCli.cs | 25 +++- 6 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 tests/CodexSdk.MAF.Tests/CodexAIAgentResultUpdateTests.cs diff --git a/src/CodexSdk.MAF/CodexAIAgent.cs b/src/CodexSdk.MAF/CodexAIAgent.cs index f6edd9b..14eddf9 100644 --- a/src/CodexSdk.MAF/CodexAIAgent.cs +++ b/src/CodexSdk.MAF/CodexAIAgent.cs @@ -82,6 +82,7 @@ protected override async Task RunCoreAsync( var responseMessages = new List(); var historyMessages = new List(); UsageDetails? usage = null; + string? finalResponse = null; var failed = false; try @@ -99,16 +100,22 @@ protected override async Task RunCoreAsync( switch (threadEvent) { + case ItemCompletedEvent { Item: AgentMessageItem completedMessage }: + finalResponse = completedMessage.Text; + break; + case TurnCompletedEvent turnCompleted: usage = turnCompleted.Usage.ToUsageDetails(); break; case TurnFailedEvent turnFailed: failed = true; + finalResponse = turnFailed.Error.Message; break; - case ThreadErrorEvent: + case ThreadErrorEvent threadError: failed = true; + finalResponse = threadError.Message; break; } @@ -129,6 +136,14 @@ protected override async Task RunCoreAsync( } } + if (finalResponse is not null) + { + // On success this carries the last agent message; on failure the error message. + responseMessages.Add( + ThreadEventExtensions.CreateResultUpdate(finalResponse, _options.ThreadOptions.Model) + .ToChatMessage()); + } + await SaveNewMessagesAsync(safeSession, mergedMessages, historyMessages, cancellationToken); return new AgentResponse @@ -160,6 +175,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA var thread = GetThread(_codex, _options, safeSession); var responseMessages = new List(); var notifiedThreadStarted = false; + string? finalResponse = null; try { @@ -174,6 +190,26 @@ protected override async IAsyncEnumerable RunCoreStreamingA cancellationToken); } + if (threadEvent is ItemCompletedEvent { Item: AgentMessageItem completedMessage }) + { + finalResponse = completedMessage.Text; + } + + var resultText = threadEvent switch + { + // On success the result carries the last agent message; on failure the error message. + TurnCompletedEvent => finalResponse, + TurnFailedEvent turnFailed => turnFailed.Error.Message, + ThreadErrorEvent threadError => threadError.Message, + _ => null, + }; + if (resultText is not null) + { + // The result update is surfaced right before the terminal event's update; + // it is intentionally not saved to chat history again. + yield return ThreadEventExtensions.CreateResultUpdate(resultText, _options.ThreadOptions.Model); + } + var update = threadEvent.ToAgentResponseUpdate(_options.ThreadOptions.Model); if (update is null) { diff --git a/src/CodexSdk.MAF/Internal/ThreadEventExtensions.cs b/src/CodexSdk.MAF/Internal/ThreadEventExtensions.cs index d01fb43..6670c1f 100644 --- a/src/CodexSdk.MAF/Internal/ThreadEventExtensions.cs +++ b/src/CodexSdk.MAF/Internal/ThreadEventExtensions.cs @@ -37,6 +37,22 @@ internal static class ThreadEventExtensions return update; } + public static AgentResponseUpdate CreateResultUpdate(string finalResponse, string? modelName = null) + { + var update = new AgentResponseUpdate + { + Role = ChatRole.Assistant, + AdditionalProperties = new AdditionalPropertiesDictionary + { + { "type", "result" }, + }, + Contents = [new TextContent(finalResponse)], + }; + update.AuthorName = AgentName; + update.AdditionalProperties[ModelNamePropertyName] = NormalizeModelName(modelName); + return update; + } + private static string NormalizeModelName(string? modelName) => string.IsNullOrWhiteSpace(modelName) ? string.Empty : modelName.Trim(); diff --git a/tests/CodexSdk.MAF.Tests/CodexAIAgentResultUpdateTests.cs b/tests/CodexSdk.MAF.Tests/CodexAIAgentResultUpdateTests.cs new file mode 100644 index 0000000..68ece2a --- /dev/null +++ b/tests/CodexSdk.MAF.Tests/CodexAIAgentResultUpdateTests.cs @@ -0,0 +1,124 @@ +using CodexSdk.TestSupport; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.CodexSdk.MAF; +using Xunit; + +namespace CodexSdk.MAF.Tests; + +public sealed class CodexAIAgentResultUpdateTests +{ + [Fact] + public async Task RunStreamingAsync_WhenTurnCompletes_EmitsResultUpdateBeforeTurnCompleted() + { + // Arrange + using var cli = new FakeCodexCli(); + var agent = new CodexAIAgent(new CodexAIAgentOptions { CodexOptions = cli.CreateOptions() }); + var session = await agent.CreateSessionAsync(TestContext.Current.CancellationToken); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync( + "hi", session, cancellationToken: TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + // Assert + var resultIndex = updates.FindIndex(update => GetUpdateType(update) == "result"); + var turnCompletedIndex = updates.FindIndex(update => GetUpdateType(update) == "turn.completed"); + Assert.True(resultIndex >= 0, "Expected a result update."); + Assert.Equal(turnCompletedIndex - 1, resultIndex); + Assert.Equal(resultIndex, updates.FindLastIndex(update => GetUpdateType(update) == "result")); + + var result = updates[resultIndex]; + Assert.Equal("started", result.Text); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.Equal("codex", result.AuthorName); + } + + [Fact] + public async Task RunAsync_WhenTurnCompletes_IncludesResultMessageWithFinalResponse() + { + // Arrange + using var cli = new FakeCodexCli(); + var agent = new CodexAIAgent(new CodexAIAgentOptions { CodexOptions = cli.CreateOptions() }); + var session = await agent.CreateSessionAsync(TestContext.Current.CancellationToken); + + // Act + var response = await agent.RunAsync( + "hi", session, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, response.Messages.Count(message => GetMessageType(message) == "result")); + + var result = response.Messages[^1]; + Assert.Equal("result", GetMessageType(result)); + Assert.Equal("started", result.Text); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.Equal("codex", result.AuthorName); + } + + [Theory] + [InlineData("turn", "turn.failed", "model unavailable")] + [InlineData("thread", "error", "stream disconnected")] + public async Task RunStreamingAsync_WhenTurnFails_EmitsResultUpdateWithErrorMessage( + string failure, string failureUpdateType, string expectedMessage) + { + // Arrange + using var cli = new FakeCodexCli(failure); + var agent = new CodexAIAgent(new CodexAIAgentOptions { CodexOptions = cli.CreateOptions() }); + var session = await agent.CreateSessionAsync(TestContext.Current.CancellationToken); + + // Act + var updates = new List(); + await foreach (var update in agent.RunStreamingAsync( + "hi", session, cancellationToken: TestContext.Current.CancellationToken)) + { + updates.Add(update); + } + + // Assert + var resultIndex = updates.FindIndex(update => GetUpdateType(update) == "result"); + var failureIndex = updates.FindIndex(update => GetUpdateType(update) == failureUpdateType); + Assert.True(resultIndex >= 0, "Expected a result update."); + Assert.Equal(failureIndex - 1, resultIndex); + Assert.Equal(resultIndex, updates.FindLastIndex(update => GetUpdateType(update) == "result")); + Assert.Equal(expectedMessage, updates[resultIndex].Text); + Assert.Equal(ChatRole.Assistant, updates[resultIndex].Role); + } + + [Theory] + [InlineData("turn", "model unavailable")] + [InlineData("thread", "stream disconnected")] + public async Task RunAsync_WhenTurnFails_IncludesResultMessageWithErrorMessage( + string failure, string expectedMessage) + { + // Arrange + using var cli = new FakeCodexCli(failure); + var agent = new CodexAIAgent(new CodexAIAgentOptions { CodexOptions = cli.CreateOptions() }); + var session = await agent.CreateSessionAsync(TestContext.Current.CancellationToken); + + // Act + var response = await agent.RunAsync( + "hi", session, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, response.Messages.Count(message => GetMessageType(message) == "result")); + + var result = response.Messages[^1]; + Assert.Equal("result", GetMessageType(result)); + Assert.Equal(expectedMessage, result.Text); + Assert.Equal(ChatRole.Assistant, result.Role); + } + + private static string? GetUpdateType(AgentResponseUpdate update) => + update.AdditionalProperties?.TryGetValue("type", out var type) == true + ? type?.ToString() + : null; + + private static string? GetMessageType(ChatMessage message) => + message.AdditionalProperties?.TryGetValue("type", out var type) == true + ? type?.ToString() + : null; +} diff --git a/tests/CodexSdk.MAF.Tests/ThreadEventExtensionsTests.cs b/tests/CodexSdk.MAF.Tests/ThreadEventExtensionsTests.cs index a0d5c3b..08729a5 100644 --- a/tests/CodexSdk.MAF.Tests/ThreadEventExtensionsTests.cs +++ b/tests/CodexSdk.MAF.Tests/ThreadEventExtensionsTests.cs @@ -139,6 +139,18 @@ public void ToChatMessage_WhenUpdateAuthorIsMissing_UsesAgentName() Assert.Equal("codex", message.AuthorName); } + [Fact] + public void CreateResultUpdate_ReturnsAssistantUpdateWithResultType() + { + var update = ThreadEventExtensions.CreateResultUpdate("final answer", " gpt-5.6-sol "); + + Assert.Equal(ChatRole.Assistant, update.Role); + Assert.Equal("codex", update.AuthorName); + Assert.Equal("result", update.AdditionalProperties!["type"]); + Assert.Equal("gpt-5.6-sol", update.AdditionalProperties["modelName"]); + Assert.Equal("final answer", Assert.IsType(Assert.Single(update.Contents)).Text); + } + [Fact] public void ToAgentResponseUpdate_WhenTurnCompletes_MapsCompleteUsage() { diff --git a/tests/FakeCodexCli/Program.cs b/tests/FakeCodexCli/Program.cs index 50c2363..1d2736c 100644 --- a/tests/FakeCodexCli/Program.cs +++ b/tests/FakeCodexCli/Program.cs @@ -13,4 +13,15 @@ type = "item.completed", item = new { id = "item_0", type = "agent_message", text = resumeIndex >= 0 ? "resumed" : "started" }, })); -Console.WriteLine("""{"type":"turn.completed","usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1}}"""); +switch (Environment.GetEnvironmentVariable("CODEX_TEST_FAILURE")) +{ + case "turn": + Console.WriteLine("""{"type":"turn.failed","error":{"message":"model unavailable"}}"""); + break; + case "thread": + Console.WriteLine("""{"type":"error","message":"stream disconnected"}"""); + break; + default: + Console.WriteLine("""{"type":"turn.completed","usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1}}"""); + break; +} diff --git a/tests/Support/FakeCodexCli.cs b/tests/Support/FakeCodexCli.cs index 51d3041..c5390bb 100644 --- a/tests/Support/FakeCodexCli.cs +++ b/tests/Support/FakeCodexCli.cs @@ -7,19 +7,32 @@ internal sealed class FakeCodexCli : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), $"codex resume tests {Guid.NewGuid():N}"); private readonly string _argumentsFile; + private readonly string? _failure; - public FakeCodexCli() + public FakeCodexCli(string? failure = null) { Directory.CreateDirectory(_directory); _argumentsFile = Path.Combine(_directory, "arguments.jsonl"); + _failure = failure; } - public Codex CreateClient() => new(new CodexOptions + public CodexOptions CreateOptions() { - CodexPathOverride = Path.Combine( - AppContext.BaseDirectory, "fake-codex", OperatingSystem.IsWindows() ? "FakeCodexCli.exe" : "FakeCodexCli"), - Env = new Dictionary { ["CODEX_TEST_ARGUMENTS_FILE"] = _argumentsFile }, - }); + var env = new Dictionary { ["CODEX_TEST_ARGUMENTS_FILE"] = _argumentsFile }; + if (_failure is not null) + { + env["CODEX_TEST_FAILURE"] = _failure; + } + + return new CodexOptions + { + CodexPathOverride = Path.Combine( + AppContext.BaseDirectory, "fake-codex", OperatingSystem.IsWindows() ? "FakeCodexCli.exe" : "FakeCodexCli"), + Env = env, + }; + } + + public Codex CreateClient() => new(CreateOptions()); public string[][] ReadArguments() => File.ReadAllLines(_argumentsFile) .Select(line => JsonSerializer.Deserialize(line)!)