Skip to content

Commit d8263be

Browse files
committed
agent framework
1 parent 299cb18 commit d8263be

32 files changed

+1806
-104
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ If no new rule is detected -> do not update the file.
8181
- in scope
8282
- out of scope
8383
- Keep context minimal and relevant; do not scan the whole repository unless required.
84+
- When adding a feature that already exists in a sibling ManagedCode SDK repository, inspect that repository first and prefer the same minimal shape; do not introduce extra abstraction layers unless Claude-specific constraints require them.
85+
- Use the neighboring `CodexSharp` repository as the structural baseline for refactors in this project; if `ClaudeCodeSharpSDK` looks more complicated without a verified Claude-specific reason, simplify it toward the Codex shape.
86+
- Apply the same CodexSharp-baseline refactor rule to the core Claude client/runtime implementation (`ClaudeClient`, `ClaudeThread`, `ClaudeExec`, options, metadata helpers), not only optional adapter packages.
87+
- Apply the same CodexSharp-baseline refactor rule to the full test layer as well; test structure, helpers, and coverage shape should stay as close as possible to CodexSharp unless Claude-specific behavior requires a difference.
8488
- Update docs for every behavior or architecture change:
8589
- `docs/Features/*` for behavior
8690
- `docs/ADR/*` for design/architecture decisions
@@ -241,3 +245,5 @@ If no new rule is detected -> do not update the file.
241245
- Custom logging abstractions when `ILogger` already solves the integration use case.
242246
- Performance tests that exercise only one parser payload and do not cover supported parsing branches.
243247
- Example code scattered across standalone sample projects instead of `README.md`.
248+
- Overengineered new layers when a simpler neighboring ManagedCode SDK already demonstrates the same feature shape.
249+
- Repository-wide overengineering in Claude-specific code when the same concern is simpler in `CodexSharp`.

ClaudeCodeSharpSDK.Extensions.AI/Extensions/ClaudeServiceCollectionExtensions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ public static IServiceCollection AddClaudeChatClient(
99
this IServiceCollection services,
1010
Action<ClaudeChatClientOptions>? configure = null)
1111
{
12+
ArgumentNullException.ThrowIfNull(services);
13+
1214
var options = new ClaudeChatClientOptions();
1315
configure?.Invoke(options);
1416
services.AddSingleton<IChatClient>(new ClaudeChatClient(options));
@@ -20,6 +22,9 @@ public static IServiceCollection AddKeyedClaudeChatClient(
2022
object serviceKey,
2123
Action<ClaudeChatClientOptions>? configure = null)
2224
{
25+
ArgumentNullException.ThrowIfNull(services);
26+
ArgumentNullException.ThrowIfNull(serviceKey);
27+
2328
var options = new ClaudeChatClientOptions();
2429
configure?.Invoke(options);
2530
services.AddKeyedSingleton<IChatClient>(serviceKey, new ClaudeChatClient(options));
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<Description>.NET Microsoft Agent Framework adapter for ClaudeCodeSharpSDK, providing AIAgent integration.</Description>
4+
<PackageId>ManagedCode.ClaudeCodeSharpSDK.Extensions.AgentFramework</PackageId>
5+
<RootNamespace>ManagedCode.ClaudeCodeSharpSDK.Extensions.AgentFramework</RootNamespace>
6+
<AssemblyName>ManagedCode.ClaudeCodeSharpSDK.Extensions.AgentFramework</AssemblyName>
7+
<PackageTags>claude;anthropic;sdk;ai;agent;cli;microsoft-agent-framework;aiagent</PackageTags>
8+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
9+
<NoWarn>$(NoWarn);CS1591</NoWarn>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<None Include="..\README.md" Pack="true" PackagePath="/" Link="README.md" Visible="false" />
14+
<PackageReference Include="DotNet.ReproducibleBuilds">
15+
<PrivateAssets>all</PrivateAssets>
16+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
17+
</PackageReference>
18+
<PackageReference Include="Microsoft.Agents.AI" />
19+
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
20+
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="all" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<ProjectReference Include="..\ClaudeCodeSharpSDK.Extensions.AI\ClaudeCodeSharpSDK.Extensions.AI.csproj" />
25+
</ItemGroup>
26+
</Project>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using ManagedCode.ClaudeCodeSharpSDK.Extensions.AI;
2+
using ManagedCode.ClaudeCodeSharpSDK.Extensions.AI.Extensions;
3+
using Microsoft.Agents.AI;
4+
using Microsoft.Extensions.AI;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Logging;
7+
8+
namespace ManagedCode.ClaudeCodeSharpSDK.Extensions.AgentFramework.Extensions;
9+
10+
public static class ClaudeServiceCollectionExtensions
11+
{
12+
public static IServiceCollection AddClaudeCodeAgent(
13+
this IServiceCollection services,
14+
Action<ClaudeChatClientOptions>? configureChatClient = null,
15+
Action<ChatClientAgentOptions>? configureAgent = null)
16+
{
17+
ArgumentNullException.ThrowIfNull(services);
18+
19+
services.AddClaudeChatClient(configureChatClient);
20+
services.AddSingleton<AIAgent>(serviceProvider => CreateAgent(serviceProvider, serviceKey: null, configureAgent));
21+
return services;
22+
}
23+
24+
public static IServiceCollection AddKeyedClaudeCodeAgent(
25+
this IServiceCollection services,
26+
object serviceKey,
27+
Action<ClaudeChatClientOptions>? configureChatClient = null,
28+
Action<ChatClientAgentOptions>? configureAgent = null)
29+
{
30+
ArgumentNullException.ThrowIfNull(services);
31+
ArgumentNullException.ThrowIfNull(serviceKey);
32+
33+
services.AddKeyedClaudeChatClient(serviceKey, configureChatClient);
34+
services.AddKeyedSingleton<AIAgent>(serviceKey, (serviceProvider, key) => CreateAgent(serviceProvider, key, configureAgent));
35+
return services;
36+
}
37+
38+
private static ChatClientAgent CreateAgent(
39+
IServiceProvider serviceProvider,
40+
object? serviceKey,
41+
Action<ChatClientAgentOptions>? configureAgent)
42+
{
43+
ArgumentNullException.ThrowIfNull(serviceProvider);
44+
45+
var options = new ChatClientAgentOptions();
46+
configureAgent?.Invoke(options);
47+
48+
var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
49+
var chatClient = serviceKey is null
50+
? serviceProvider.GetRequiredService<IChatClient>()
51+
: serviceProvider.GetRequiredKeyedService<IChatClient>(serviceKey);
52+
53+
return chatClient.AsAIAgent(options, loggerFactory, serviceProvider);
54+
}
55+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo(AssemblyNames.TestsAssemblyName)]
4+
5+
internal static class AssemblyNames
6+
{
7+
internal const string TestsAssemblyName = "ManagedCode.ClaudeCodeSharpSDK.Tests";
8+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
using ManagedCode.ClaudeCodeSharpSDK.Extensions.AgentFramework.Extensions;
2+
using Microsoft.Agents.AI;
3+
using Microsoft.Extensions.AI;
4+
using Microsoft.Extensions.DependencyInjection;
5+
6+
namespace ManagedCode.ClaudeCodeSharpSDK.Tests.AgentFramework;
7+
8+
public class ClaudeServiceCollectionExtensionsTests
9+
{
10+
private const string AgentDescription = "Agent description";
11+
private const string AgentInstructions = "You are a coding assistant.";
12+
private const string AgentName = "claude-agent";
13+
private const string ConfiguredDefaultModel = "configured-default-model";
14+
private const string KeyedServiceName = "claude-agent";
15+
private const string ProviderName = "ClaudeCodeCLI";
16+
private const string ServicesParameterName = "services";
17+
private const string ServiceKeyParameterName = "serviceKey";
18+
19+
[Test]
20+
public async Task AddClaudeCodeAgent_ThrowsForNullServices()
21+
{
22+
ServiceCollection? services = null;
23+
24+
var action = () => services!.AddClaudeCodeAgent();
25+
26+
var exception = await Assert.That(action).ThrowsException();
27+
await Assert.That(exception).IsTypeOf<ArgumentNullException>();
28+
await Assert.That(((ArgumentNullException)exception!).ParamName).IsEqualTo(ServicesParameterName);
29+
}
30+
31+
[Test]
32+
public async Task AddClaudeCodeAgent_RegistersAIAgentAndChatClient()
33+
{
34+
var services = new ServiceCollection();
35+
services.AddClaudeCodeAgent();
36+
37+
var provider = services.BuildServiceProvider();
38+
var agent = provider.GetService<AIAgent>();
39+
var chatClient = provider.GetService<IChatClient>();
40+
var resolvedAgentChatClient = agent?.GetService(typeof(IChatClient)) as IChatClient;
41+
var metadata = resolvedAgentChatClient?.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata;
42+
43+
await Assert.That(agent).IsNotNull();
44+
await Assert.That(chatClient).IsNotNull();
45+
await Assert.That(resolvedAgentChatClient).IsNotNull();
46+
await Assert.That(metadata).IsNotNull();
47+
await Assert.That(metadata!.ProviderName).IsEqualTo(ProviderName);
48+
}
49+
50+
[Test]
51+
public async Task AddClaudeCodeAgent_WithConfiguration_AppliesAgentOptions()
52+
{
53+
var services = new ServiceCollection();
54+
services.AddClaudeCodeAgent(
55+
configureChatClient: options => options.DefaultModel = ConfiguredDefaultModel,
56+
configureAgent: options =>
57+
{
58+
options.Name = AgentName;
59+
options.Description = AgentDescription;
60+
options.ChatOptions = new ChatOptions
61+
{
62+
Instructions = AgentInstructions,
63+
};
64+
});
65+
66+
var provider = services.BuildServiceProvider();
67+
var agent = provider.GetRequiredService<AIAgent>();
68+
var chatClient = agent.GetService<IChatClient>();
69+
var metadata = chatClient?.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata;
70+
var agentOptions = agent.GetService<ChatClientAgentOptions>();
71+
72+
await Assert.That(agent.Name).IsEqualTo(AgentName);
73+
await Assert.That(agent.Description).IsEqualTo(AgentDescription);
74+
await Assert.That(agentOptions).IsNotNull();
75+
await Assert.That(agentOptions!.ChatOptions).IsNotNull();
76+
await Assert.That(agentOptions.ChatOptions!.Instructions).IsEqualTo(AgentInstructions);
77+
await Assert.That(metadata).IsNotNull();
78+
await Assert.That(metadata!.DefaultModelId).IsEqualTo(ConfiguredDefaultModel);
79+
}
80+
81+
[Test]
82+
public async Task AddKeyedClaudeCodeAgent_RegistersKeyedAgent()
83+
{
84+
var services = new ServiceCollection();
85+
services.AddKeyedClaudeCodeAgent(KeyedServiceName);
86+
87+
var provider = services.BuildServiceProvider();
88+
var agent = provider.GetKeyedService<AIAgent>(KeyedServiceName);
89+
var chatClient = provider.GetKeyedService<IChatClient>(KeyedServiceName);
90+
var resolvedAgentChatClient = agent?.GetService(typeof(IChatClient)) as IChatClient;
91+
var metadata = resolvedAgentChatClient?.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata;
92+
93+
await Assert.That(agent).IsNotNull();
94+
await Assert.That(chatClient).IsNotNull();
95+
await Assert.That(resolvedAgentChatClient).IsNotNull();
96+
await Assert.That(metadata).IsNotNull();
97+
await Assert.That(metadata!.ProviderName).IsEqualTo(ProviderName);
98+
}
99+
100+
[Test]
101+
public async Task AddKeyedClaudeCodeAgent_ThrowsForNullServiceKey()
102+
{
103+
var services = new ServiceCollection();
104+
105+
var action = () => services.AddKeyedClaudeCodeAgent(serviceKey: null!);
106+
107+
var exception = await Assert.That(action).ThrowsException();
108+
await Assert.That(exception).IsTypeOf<ArgumentNullException>();
109+
await Assert.That(((ArgumentNullException)exception!).ParamName).IsEqualTo(ServiceKeyParameterName);
110+
}
111+
112+
[Test]
113+
public async Task AddKeyedClaudeCodeAgent_WithConfiguration_AppliesKeyedAgentOptions()
114+
{
115+
var services = new ServiceCollection();
116+
services.AddKeyedClaudeCodeAgent(
117+
KeyedServiceName,
118+
configureChatClient: options => options.DefaultModel = ConfiguredDefaultModel,
119+
configureAgent: options =>
120+
{
121+
options.Name = AgentName;
122+
options.ChatOptions = new ChatOptions
123+
{
124+
Instructions = AgentInstructions,
125+
};
126+
});
127+
128+
var provider = services.BuildServiceProvider();
129+
var agent = provider.GetRequiredKeyedService<AIAgent>(KeyedServiceName);
130+
var chatClient = agent.GetService<IChatClient>();
131+
var metadata = chatClient?.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata;
132+
var agentOptions = agent.GetService<ChatClientAgentOptions>();
133+
134+
await Assert.That(agent.Name).IsEqualTo(AgentName);
135+
await Assert.That(agentOptions).IsNotNull();
136+
await Assert.That(agentOptions!.ChatOptions).IsNotNull();
137+
await Assert.That(agentOptions.ChatOptions!.Instructions).IsEqualTo(AgentInstructions);
138+
await Assert.That(metadata).IsNotNull();
139+
await Assert.That(metadata!.DefaultModelId).IsEqualTo(ConfiguredDefaultModel);
140+
}
141+
}

ClaudeCodeSharpSDK.Tests/ClaudeCodeSharpSDK.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
<ItemGroup>
2323
<ProjectReference Include="..\ClaudeCodeSharpSDK.Extensions.AI\ClaudeCodeSharpSDK.Extensions.AI.csproj" />
24+
<ProjectReference Include="..\ClaudeCodeSharpSDK.Extensions.AgentFramework\ClaudeCodeSharpSDK.Extensions.AgentFramework.csproj" />
2425
<ProjectReference Include="..\ClaudeCodeSharpSDK\ClaudeCodeSharpSDK.csproj" />
2526
</ItemGroup>
2627
</Project>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using ManagedCode.ClaudeCodeSharpSDK.Client;
2+
using ManagedCode.ClaudeCodeSharpSDK.Execution;
3+
using ManagedCode.ClaudeCodeSharpSDK.Internal;
4+
using ManagedCode.ClaudeCodeSharpSDK.Models;
5+
using ManagedCode.ClaudeCodeSharpSDK.Tests.Shared;
6+
7+
namespace ManagedCode.ClaudeCodeSharpSDK.Tests.Integration;
8+
9+
[Property(TestConstants.RequiresClaudeAuthPropertyName, TestConstants.TrueString)]
10+
[RequiresAuthenticatedClaude]
11+
public class ClaudeExecIntegrationTests
12+
{
13+
private const string CliExitCodeMessageFragment = "exited with code";
14+
private const string FirstPrompt = "Reply with short plain text: first.";
15+
private const string InvalidModel = "__claudecodesharpsdk_invalid_model__";
16+
private const string SecondPrompt = "Reply with short plain text: second.";
17+
private static readonly TimeSpan ExecTimeout = TimeSpan.FromMinutes(2);
18+
private static readonly TimeSpan ThreadTimeout = TimeSpan.FromMinutes(3);
19+
20+
[Test]
21+
public async Task RunAsync_UsesDefaultProcessRunner_EndToEnd()
22+
{
23+
var settings = RealClaudeTestSupport.GetRequiredSettings();
24+
25+
var exec = new ClaudeExec(settings.ExecutablePath);
26+
using var cancellation = new CancellationTokenSource(ExecTimeout);
27+
28+
var lines = await DrainToListAsync(exec.RunAsync(new ClaudeExecArgs
29+
{
30+
Input = FirstPrompt,
31+
Model = settings.Model,
32+
DangerouslySkipPermissions = true,
33+
NoSessionPersistence = true,
34+
CancellationToken = cancellation.Token,
35+
}));
36+
var parsedEvents = lines.Select(ThreadEventParser.Parse).ToList();
37+
38+
await Assert.That(parsedEvents.Any(static threadEvent => threadEvent is ThreadStartedEvent)).IsTrue();
39+
await Assert.That(parsedEvents.Any(static threadEvent => threadEvent is TurnCompletedEvent)).IsTrue();
40+
}
41+
42+
[Test]
43+
public async Task RunAsync_SecondCallPassesResumeArgument_EndToEnd()
44+
{
45+
var settings = RealClaudeTestSupport.GetRequiredSettings();
46+
47+
using var client = RealClaudeTestSupport.CreateClient();
48+
using var cancellation = new CancellationTokenSource(ThreadTimeout);
49+
50+
using var thread = client.StartThread(new ThreadOptions
51+
{
52+
Model = settings.Model,
53+
DangerouslySkipPermissions = true,
54+
});
55+
56+
var firstResult = await thread.RunAsync(
57+
FirstPrompt,
58+
new TurnOptions { CancellationToken = cancellation.Token });
59+
60+
var threadId = thread.Id;
61+
await Assert.That(threadId).IsNotNull();
62+
await Assert.That(firstResult.Usage).IsNotNull();
63+
64+
var secondResult = await thread.RunAsync(
65+
SecondPrompt,
66+
new TurnOptions { CancellationToken = cancellation.Token });
67+
68+
await Assert.That(secondResult.Usage).IsNotNull();
69+
await Assert.That(thread.Id).IsEqualTo(threadId);
70+
}
71+
72+
[Test]
73+
public async Task RunAsync_PropagatesNonZeroExitCode_EndToEnd()
74+
{
75+
var settings = RealClaudeTestSupport.GetRequiredSettings();
76+
77+
var exec = new ClaudeExec(settings.ExecutablePath);
78+
using var cancellation = new CancellationTokenSource(ExecTimeout);
79+
80+
var action = async () => await DrainAsync(exec.RunAsync(new ClaudeExecArgs
81+
{
82+
Input = FirstPrompt,
83+
Model = InvalidModel,
84+
DangerouslySkipPermissions = true,
85+
NoSessionPersistence = true,
86+
CancellationToken = cancellation.Token,
87+
}));
88+
89+
var exception = await Assert.That(action).ThrowsException();
90+
await Assert.That(exception).IsTypeOf<InvalidOperationException>();
91+
await Assert.That(exception!.Message).Contains(CliExitCodeMessageFragment);
92+
}
93+
94+
private static async Task DrainAsync(IAsyncEnumerable<string> lines)
95+
{
96+
await foreach (var _ in lines)
97+
{
98+
// Intentionally empty.
99+
}
100+
}
101+
102+
private static async Task<List<string>> DrainToListAsync(IAsyncEnumerable<string> lines)
103+
{
104+
var result = new List<string>();
105+
106+
await foreach (var line in lines)
107+
{
108+
result.Add(line);
109+
}
110+
111+
return result;
112+
}
113+
}

0 commit comments

Comments
 (0)