Skip to content

Commit d2203c0

Browse files
authored
Merge pull request #2 from luisquintanilla/feature/meai-integration
feat: Microsoft.Extensions.AI IChatClient integration
2 parents cc6fd76 + 5766646 commit d2203c0

28 files changed

+1342
-1
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using ManagedCode.CodexSharpSDK.Extensions.AI.Internal;
2+
using ManagedCode.CodexSharpSDK.Models;
3+
using Microsoft.Extensions.AI;
4+
5+
namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests;
6+
7+
public class ChatMessageMapperTests
8+
{
9+
[Test]
10+
public async Task ToCodexInput_TextOnly_ReturnsPrompt()
11+
{
12+
var messages = new[] { new ChatMessage(ChatRole.User, "Hello world") };
13+
var (prompt, images) = ChatMessageMapper.ToCodexInput(messages);
14+
await Assert.That(prompt).IsEqualTo("Hello world");
15+
await Assert.That(images).Count().IsEqualTo(0);
16+
}
17+
18+
[Test]
19+
public async Task ToCodexInput_SystemAndUser_PrependsSystemPrefix()
20+
{
21+
var messages = new[]
22+
{
23+
new ChatMessage(ChatRole.System, "You are helpful"),
24+
new ChatMessage(ChatRole.User, "Help me"),
25+
};
26+
var (prompt, _) = ChatMessageMapper.ToCodexInput(messages);
27+
await Assert.That(prompt).Contains("[System] You are helpful");
28+
await Assert.That(prompt).Contains("Help me");
29+
}
30+
31+
[Test]
32+
public async Task ToCodexInput_AssistantMessage_AppendsAssistantPrefix()
33+
{
34+
var messages = new[]
35+
{
36+
new ChatMessage(ChatRole.User, "Question"),
37+
new ChatMessage(ChatRole.Assistant, "Answer"),
38+
new ChatMessage(ChatRole.User, "Follow up"),
39+
};
40+
var (prompt, _) = ChatMessageMapper.ToCodexInput(messages);
41+
await Assert.That(prompt).Contains("[Assistant] Answer");
42+
await Assert.That(prompt).Contains("Follow up");
43+
}
44+
45+
[Test]
46+
public async Task ToCodexInput_ImageContent_ExtractedSeparately()
47+
{
48+
var imageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header
49+
var messages = new[]
50+
{
51+
new ChatMessage(ChatRole.User,
52+
[
53+
new TextContent("Describe this"),
54+
new DataContent(imageData, "image/png"),
55+
]),
56+
};
57+
var (prompt, images) = ChatMessageMapper.ToCodexInput(messages);
58+
await Assert.That(prompt).Contains("Describe this");
59+
await Assert.That(images).Count().IsEqualTo(1);
60+
}
61+
62+
[Test]
63+
public async Task ToCodexInput_EmptyMessages_ReturnsEmpty()
64+
{
65+
var (prompt, images) = ChatMessageMapper.ToCodexInput([]);
66+
await Assert.That(prompt).IsEqualTo(string.Empty);
67+
await Assert.That(images).Count().IsEqualTo(0);
68+
}
69+
70+
[Test]
71+
public async Task BuildUserInput_NoImages_ReturnsSingleTextInput()
72+
{
73+
var result = ChatMessageMapper.BuildUserInput("Hello", []);
74+
await Assert.That(result).Count().IsEqualTo(1);
75+
await Assert.That(result[0]).IsTypeOf<TextInput>();
76+
}
77+
78+
[Test]
79+
public async Task BuildUserInput_WithImages_ReturnsTextAndImageInputs()
80+
{
81+
var imageData = new byte[] { 0xFF, 0xD8, 0xFF }; // JPEG header
82+
var images = new List<DataContent> { new(imageData, "image/jpeg") };
83+
var result = ChatMessageMapper.BuildUserInput("Look at this", images);
84+
await Assert.That(result.Count).IsGreaterThanOrEqualTo(2);
85+
await Assert.That(result[0]).IsTypeOf<TextInput>();
86+
}
87+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using ManagedCode.CodexSharpSDK.Client;
2+
using ManagedCode.CodexSharpSDK.Extensions.AI.Internal;
3+
using Microsoft.Extensions.AI;
4+
5+
namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests;
6+
7+
public class ChatOptionsMapperTests
8+
{
9+
[Test]
10+
public async Task ToThreadOptions_NullOptions_UsesDefaults()
11+
{
12+
var clientOptions = new CodexChatClientOptions { DefaultModel = "test-model" };
13+
var result = ChatOptionsMapper.ToThreadOptions(null, clientOptions);
14+
await Assert.That(result.Model).IsEqualTo("test-model");
15+
}
16+
17+
[Test]
18+
public async Task ToThreadOptions_ModelId_MapsToModel()
19+
{
20+
var chatOptions = new ChatOptions { ModelId = "gpt-5" };
21+
var clientOptions = new CodexChatClientOptions { DefaultModel = "default" };
22+
var result = ChatOptionsMapper.ToThreadOptions(chatOptions, clientOptions);
23+
await Assert.That(result.Model).IsEqualTo("gpt-5");
24+
}
25+
26+
[Test]
27+
public async Task ToThreadOptions_AdditionalProperties_MapsCodexKeys()
28+
{
29+
var chatOptions = new ChatOptions
30+
{
31+
AdditionalProperties = new AdditionalPropertiesDictionary
32+
{
33+
[ChatOptionsMapper.SandboxModeKey] = SandboxMode.WorkspaceWrite,
34+
[ChatOptionsMapper.FullAutoKey] = true,
35+
[ChatOptionsMapper.ProfileKey] = "strict",
36+
[ChatOptionsMapper.ReasoningEffortKey] = ModelReasoningEffort.High,
37+
},
38+
};
39+
var result = ChatOptionsMapper.ToThreadOptions(chatOptions, new CodexChatClientOptions());
40+
await Assert.That(result.SandboxMode).IsEqualTo(SandboxMode.WorkspaceWrite);
41+
await Assert.That(result.FullAuto).IsTrue();
42+
await Assert.That(result.Profile).IsEqualTo("strict");
43+
await Assert.That(result.ModelReasoningEffort).IsEqualTo(ModelReasoningEffort.High);
44+
}
45+
46+
[Test]
47+
public async Task ToTurnOptions_SetsCancellationToken()
48+
{
49+
using var cts = new CancellationTokenSource();
50+
var result = ChatOptionsMapper.ToTurnOptions(null, cts.Token);
51+
await Assert.That(result.CancellationToken).IsEqualTo(cts.Token);
52+
}
53+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using ManagedCode.CodexSharpSDK.Extensions.AI.Content;
2+
using ManagedCode.CodexSharpSDK.Extensions.AI.Internal;
3+
using ManagedCode.CodexSharpSDK.Models;
4+
using Microsoft.Extensions.AI;
5+
6+
namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests;
7+
8+
public class ChatResponseMapperTests
9+
{
10+
[Test]
11+
public async Task ToChatResponse_BasicResult_MapsCorrectly()
12+
{
13+
var result = new RunResult([], "Hello from Codex", new Usage(100, 10, 50));
14+
var response = ChatResponseMapper.ToChatResponse(result, "thread-123");
15+
16+
await Assert.That(response.Text).Contains("Hello from Codex");
17+
await Assert.That(response.ConversationId).IsEqualTo("thread-123");
18+
await Assert.That(response.Usage).IsNotNull();
19+
await Assert.That(response.Usage!.InputTokenCount).IsEqualTo(100);
20+
await Assert.That(response.Usage!.OutputTokenCount).IsEqualTo(50);
21+
await Assert.That(response.Usage!.TotalTokenCount).IsEqualTo(150);
22+
}
23+
24+
[Test]
25+
public async Task ToChatResponse_NullUsage_NoUsageSet()
26+
{
27+
var result = new RunResult([], "Response", null);
28+
var response = ChatResponseMapper.ToChatResponse(result, null);
29+
await Assert.That(response.Usage).IsNull();
30+
await Assert.That(response.ConversationId).IsNull();
31+
}
32+
33+
[Test]
34+
public async Task ToChatResponse_WithReasoningItem_MapsToTextReasoningContent()
35+
{
36+
var items = new List<ThreadItem>
37+
{
38+
new ReasoningItem("r1", "thinking about this..."),
39+
};
40+
var result = new RunResult(items, "Final answer", null);
41+
var response = ChatResponseMapper.ToChatResponse(result, null);
42+
43+
var contents = response.Messages[0].Contents;
44+
await Assert.That(contents.OfType<TextReasoningContent>().Count()).IsEqualTo(1);
45+
}
46+
47+
[Test]
48+
public async Task ToChatResponse_WithCommandExecution_MapsToCustomContent()
49+
{
50+
var items = new List<ThreadItem>
51+
{
52+
new CommandExecutionItem("c1", "npm test", "all passed", 0, CommandExecutionStatus.Completed),
53+
};
54+
var result = new RunResult(items, "Done", null);
55+
var response = ChatResponseMapper.ToChatResponse(result, null);
56+
57+
var cmdContent = response.Messages[0].Contents.OfType<CommandExecutionContent>().Single();
58+
await Assert.That(cmdContent.Command).IsEqualTo("npm test");
59+
await Assert.That(cmdContent.ExitCode).IsEqualTo(0);
60+
}
61+
62+
[Test]
63+
public async Task ToChatResponse_WithFileChange_MapsToCustomContent()
64+
{
65+
var items = new List<ThreadItem>
66+
{
67+
new FileChangeItem("f1", [new FileUpdateChange("src/app.cs", PatchChangeKind.Update)], PatchApplyStatus.Completed),
68+
};
69+
var result = new RunResult(items, "Fixed", null);
70+
var response = ChatResponseMapper.ToChatResponse(result, null);
71+
72+
var fileContent = response.Messages[0].Contents.OfType<FileChangeContent>().Single();
73+
await Assert.That(fileContent.Changes).Count().IsEqualTo(1);
74+
}
75+
76+
[Test]
77+
public async Task ToChatResponse_CachedTokens_IncludedInUsage()
78+
{
79+
var result = new RunResult([], "Response", new Usage(100, 50, 25));
80+
var response = ChatResponseMapper.ToChatResponse(result, null);
81+
await Assert.That(response.Usage!.CachedInputTokenCount).IsEqualTo(50);
82+
}
83+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using ManagedCode.CodexSharpSDK.Extensions.AI.Extensions;
2+
using Microsoft.Extensions.AI;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests;
6+
7+
public class CodexServiceCollectionExtensionsTests
8+
{
9+
[Test]
10+
public async Task AddCodexChatClient_RegistersIChatClient()
11+
{
12+
var services = new ServiceCollection();
13+
services.AddCodexChatClient();
14+
var provider = services.BuildServiceProvider();
15+
var client = provider.GetService<IChatClient>();
16+
await Assert.That(client).IsNotNull();
17+
await Assert.That(client).IsTypeOf<CodexChatClient>();
18+
}
19+
20+
[Test]
21+
public async Task AddCodexChatClient_WithConfiguration_RegistersIChatClient()
22+
{
23+
var services = new ServiceCollection();
24+
services.AddCodexChatClient(_ => { });
25+
var provider = services.BuildServiceProvider();
26+
var client = provider.GetService<IChatClient>();
27+
await Assert.That(client).IsNotNull();
28+
}
29+
30+
[Test]
31+
public async Task AddKeyedCodexChatClient_RegistersWithKey()
32+
{
33+
var services = new ServiceCollection();
34+
services.AddKeyedCodexChatClient("codex");
35+
var provider = services.BuildServiceProvider();
36+
var client = provider.GetKeyedService<IChatClient>("codex");
37+
await Assert.That(client).IsNotNull();
38+
}
39+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Exe</OutputType>
4+
<IsPackable>false</IsPackable>
5+
<IsTestProject>true</IsTestProject>
6+
<RootNamespace>ManagedCode.CodexSharpSDK.Extensions.AI.Tests</RootNamespace>
7+
<AssemblyName>ManagedCode.CodexSharpSDK.Extensions.AI.Tests</AssemblyName>
8+
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
9+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
10+
<NoWarn>$(NoWarn);CA1707;CS1591</NoWarn>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="TUnit" />
15+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<ProjectReference Include="..\CodexSharpSDK.Extensions.AI\CodexSharpSDK.Extensions.AI.csproj" />
20+
<ProjectReference Include="..\CodexSharpSDK\CodexSharpSDK.csproj" />
21+
</ItemGroup>
22+
</Project>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using ManagedCode.CodexSharpSDK.Extensions.AI.Content;
2+
using ManagedCode.CodexSharpSDK.Extensions.AI.Internal;
3+
using ManagedCode.CodexSharpSDK.Models;
4+
using Microsoft.Extensions.AI;
5+
6+
namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests;
7+
8+
public class StreamingEventMapperTests
9+
{
10+
[Test]
11+
public async Task ToUpdates_ThreadStarted_YieldsConversationId()
12+
{
13+
var events = ToAsyncEnumerable(new ThreadStartedEvent("thread-1"));
14+
var updates = await CollectUpdates(events);
15+
await Assert.That(updates[0].ConversationId).IsEqualTo("thread-1");
16+
}
17+
18+
[Test]
19+
public async Task ToUpdates_AgentMessage_YieldsTextContent()
20+
{
21+
var events = ToAsyncEnumerable(
22+
new ItemCompletedEvent(new AgentMessageItem("m1", "Hello")));
23+
var updates = await CollectUpdates(events);
24+
await Assert.That(updates[0].Text).IsEqualTo("Hello");
25+
await Assert.That(updates[0].Role).IsEqualTo(ChatRole.Assistant);
26+
}
27+
28+
[Test]
29+
public async Task ToUpdates_TurnCompleted_YieldsFinishReason()
30+
{
31+
var events = ToAsyncEnumerable(
32+
new TurnCompletedEvent(new Usage(10, 0, 5)));
33+
var updates = await CollectUpdates(events);
34+
await Assert.That(updates[0].FinishReason).IsEqualTo(ChatFinishReason.Stop);
35+
}
36+
37+
[Test]
38+
public async Task ToUpdates_TurnFailed_ThrowsException()
39+
{
40+
var events = ToAsyncEnumerable(
41+
new TurnFailedEvent(new ThreadError("something broke")));
42+
43+
await Assert.That(async () => await CollectUpdates(events))
44+
.ThrowsExactly<InvalidOperationException>();
45+
}
46+
47+
[Test]
48+
public async Task ToUpdates_CommandExecution_YieldsCustomContent()
49+
{
50+
var events = ToAsyncEnumerable(
51+
new ItemCompletedEvent(
52+
new CommandExecutionItem("c1", "ls", "file.txt", 0, CommandExecutionStatus.Completed)));
53+
var updates = await CollectUpdates(events);
54+
var content = updates[0].Contents.OfType<CommandExecutionContent>().Single();
55+
await Assert.That(content.Command).IsEqualTo("ls");
56+
}
57+
58+
[Test]
59+
public async Task ToUpdates_FullSequence_MapsAllEvents()
60+
{
61+
var events = ToAsyncEnumerable(
62+
new ThreadStartedEvent("t1"),
63+
new TurnStartedEvent(),
64+
new ItemCompletedEvent(new ReasoningItem("r1", "thinking")),
65+
new ItemCompletedEvent(new AgentMessageItem("m1", "answer")),
66+
new TurnCompletedEvent(new Usage(10, 0, 5)));
67+
68+
var updates = await CollectUpdates(events);
69+
// TurnStartedEvent is not matched in the switch, so 4 updates expected
70+
await Assert.That(updates.Count).IsGreaterThanOrEqualTo(4);
71+
}
72+
73+
private static async IAsyncEnumerable<ThreadEvent> ToAsyncEnumerable(params ThreadEvent[] events)
74+
{
75+
foreach (var evt in events)
76+
{
77+
yield return evt;
78+
await Task.CompletedTask;
79+
}
80+
}
81+
82+
private static async Task<List<ChatResponseUpdate>> CollectUpdates(IAsyncEnumerable<ThreadEvent> events)
83+
{
84+
var updates = new List<ChatResponseUpdate>();
85+
await foreach (var update in StreamingEventMapper.ToUpdates(events))
86+
{
87+
updates.Add(update);
88+
}
89+
90+
return updates;
91+
}
92+
}

0 commit comments

Comments
 (0)