Skip to content
Merged
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
38 changes: 37 additions & 1 deletion src/CodexSdk.MAF/CodexAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ protected override async Task<AgentResponse> RunCoreAsync(
var responseMessages = new List<ChatMessage>();
var historyMessages = new List<ChatMessage>();
UsageDetails? usage = null;
string? finalResponse = null;
var failed = false;

try
Expand All @@ -99,16 +100,22 @@ protected override async Task<AgentResponse> 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;
}

Expand All @@ -129,6 +136,14 @@ protected override async Task<AgentResponse> 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
Expand Down Expand Up @@ -160,6 +175,7 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingA
var thread = GetThread(_codex, _options, safeSession);
var responseMessages = new List<ChatMessage>();
var notifiedThreadStarted = false;
string? finalResponse = null;

try
{
Expand All @@ -174,6 +190,26 @@ protected override async IAsyncEnumerable<AgentResponseUpdate> 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)
{
Expand Down
16 changes: 16 additions & 0 deletions src/CodexSdk.MAF/Internal/ThreadEventExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
124 changes: 124 additions & 0 deletions tests/CodexSdk.MAF.Tests/CodexAIAgentResultUpdateTests.cs
Original file line number Diff line number Diff line change
@@ -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<AgentResponseUpdate>();
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<AgentResponseUpdate>();
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;
}
12 changes: 12 additions & 0 deletions tests/CodexSdk.MAF.Tests/ThreadEventExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextContent>(Assert.Single(update.Contents)).Text);
}

[Fact]
public void ToAgentResponseUpdate_WhenTurnCompletes_MapsCompleteUsage()
{
Expand Down
13 changes: 12 additions & 1 deletion tests/FakeCodexCli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
25 changes: 19 additions & 6 deletions tests/Support/FakeCodexCli.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["CODEX_TEST_ARGUMENTS_FILE"] = _argumentsFile },
});
var env = new Dictionary<string, string> { ["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<string[]>(line)!)
Expand Down