Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs

194 lines
11 KiB
C#

using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.ClaudeCode;
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
public class ProviderExtensionTests
{
/// <summary>Records registrations; every action throws so a misuse during activation is loud.</summary>
private sealed class FakeExtensionApi : IExtensionApi
{
public List<ProviderRegistration> Providers { get; } = [];
public Dictionary<string, CommandDefinition> Commands { get; } = new();
public Dictionary<Type, Delegate> Handlers { get; } = new();
public List<(CustomMessage Message, MessageDelivery Delivery, bool TriggerTurn)> Sent { get; } = [];
public string ExtensionName => "claude-code-provider";
public ILogger Logger => NullLogger.Instance;
public IEventBus Events => throw new NotSupportedException();
public IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent
{
Handlers[typeof(TEvent)] = handler;
return new Subscription();
}
public void RegisterTool(ITool tool) => throw new NotSupportedException();
public void RegisterCommand(string name, CommandDefinition command) => Commands[name] = command;
public void RegisterProvider(ProviderRegistration provider) => Providers.Add(provider);
public void UnregisterProvider(string providerId) => throw new NotSupportedException();
public void SendMessage(CustomMessage message, MessageDelivery delivery = MessageDelivery.Steer, bool triggerTurn = true) => Sent.Add((message, delivery, triggerTurn));
public void SendUserMessage(string text, IReadOnlyList<ImageContent>? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException();
public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException();
public Task<bool> SetModelAsync(Model model, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ThinkingLevel GetThinkingLevel() => throw new NotSupportedException();
public void SetThinkingLevel(ThinkingLevel level) => throw new NotSupportedException();
public IReadOnlyList<string> GetActiveTools() => throw new NotSupportedException();
public void SetActiveTools(IEnumerable<string> names) => throw new NotSupportedException();
public IReadOnlyList<ToolInfo> GetAllTools() => throw new NotSupportedException();
public IReadOnlyList<CommandInfo> GetCommands() => throw new NotSupportedException();
public string? GetSessionName() => throw new NotSupportedException();
public void SetSessionName(string? name) => throw new NotSupportedException();
public Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask Raise<TEvent>(TEvent e, IExtensionContext ctx) where TEvent : ExtensionEvent =>
((ExtensionHandler<TEvent>)Handlers[typeof(TEvent)])(e, ctx, CancellationToken.None);
private sealed class Subscription : IDisposable { public void Dispose() { } }
}
private sealed class FakeContext(string cwd, string sessionId, IUserInterface? ui = null) : IExtensionCommandContext
{
public string Cwd => cwd;
public string SessionId => sessionId;
public bool HasUi => ui is not null;
public IUserInterface Ui => ui ?? NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
public Task<SwitchResult> SwitchSessionAsync(string sessionId, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false));
}
private sealed class RecordingUi : IUserInterface
{
public List<(string Message, NotifyKind Kind)> Notices { get; } = [];
public Task<string?> SelectAsync(string title, IReadOnlyList<string> options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public Task<bool> ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false);
public Task<string?> InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
public void Notify(string message, NotifyKind kind = NotifyKind.Info) => Notices.Add((message, kind));
public void SetStatus(string key, string? text) { }
}
[Fact]
public async Task Activation_registers_the_provider_the_doctor_command_and_session_hooks()
{
var api = new FakeExtensionApi();
var registry = new SessionRegistry();
var extension = new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = "/nonexistent/claude", Registry = registry, GetEnv = _ => null });
Assert.Equal("claude-code-provider", extension.Name);
await extension.ActivateAsync(api, CancellationToken.None);
var provider = Assert.Single(api.Providers);
Assert.Equal("claude-code", provider.Id);
Assert.Equal("Claude Code (subscription)", provider.Name);
Assert.Equal("claude-code-headless", provider.Api);
Assert.Equal("claude-code://local", provider.BaseUrl);
Assert.False(provider.RequiresApiKey);
Assert.Equal(["sonnet", "opus", "haiku", "fable"], provider.Models.Select(m => m.Id).ToArray());
Assert.All(provider.Models, m => Assert.Equal(ModelCost.Zero, m.Cost));
Assert.Equal(1_000_000, provider.Models[0].ContextWindow);
Assert.Equal(64_000, provider.Models[0].MaxTokens);
Assert.True(provider.Models[0].Reasoning);
Assert.False(provider.Models[2].Reasoning);
Assert.Equal("xhigh", provider.Models[0].ThinkingLevelMap![ThinkingLevel.XHigh]);
Assert.Null(provider.Models[0].ThinkingLevelMap![ThinkingLevel.Off]);
Assert.Contains(ModelInput.Image, provider.Models[1].Input);
var command = Assert.Single(api.Commands);
Assert.Equal("claude-code-doctor", command.Key);
Assert.NotNull(command.Value.Description);
Assert.Equal(3, api.Handlers.Count);
Assert.NotNull(extension.Api);
}
[Fact]
public async Task Session_hooks_register_and_remove_sessions_and_message_end_normalises_overflow()
{
var api = new FakeExtensionApi();
var registry = new SessionRegistry();
var ui = new RecordingUi();
var cwd = Directory.CreateTempSubdirectory("ext-test-").FullName;
try
{
await new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = null, Registry = registry, GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
var ctx = new FakeContext(cwd, "session-1", ui);
await api.Raise(new SessionStartEvent(SessionStartReason.Startup), ctx);
var entry = registry.Get("session-1");
Assert.NotNull(entry);
Assert.Equal(cwd, entry.Cwd);
Assert.NotNull(entry.Notify);
Assert.Contains(ui.Notices, n => n.Message.Contains("not found") && n.Kind == NotifyKind.Warning);
// A second start for a new session id drops the earlier registration.
await api.Raise(new SessionStartEvent(SessionStartReason.New, "session-1"), new FakeContext(cwd, "session-2"));
Assert.Null(registry.Get("session-1"));
Assert.Null(registry.Get("session-2")!.Notify);
await api.Raise(new SessionShutdownEvent(SessionShutdownReason.Quit), new FakeContext(cwd, "session-2"));
Assert.Equal(0, registry.Count);
var overflow = new MessageEndEvent(new AssistantMessage { Provider = "claude-code", Api = "claude-code-headless", Model = "sonnet", StopReason = StopReason.Error, ErrorMessage = "[protocol_stream] Claude Code request failed (400): Prompt is too long" });
await api.Raise(overflow, ctx);
Assert.Equal("context_length_exceeded: [protocol_stream] Claude Code request failed (400): Prompt is too long", Assert.IsType<AssistantMessage>(overflow.Message).ErrorMessage);
var already = new AssistantMessage { Provider = "claude-code", Api = "x", Model = "m", StopReason = StopReason.Error, ErrorMessage = "context_length_exceeded: too long" };
var unchanged = new MessageEndEvent(already);
await api.Raise(unchanged, ctx);
Assert.Same(already, unchanged.Message);
var other = new UserMessage { Content = [new TextContent("prompt is too long")] };
var user = new MessageEndEvent(other);
await api.Raise(user, ctx);
Assert.Same(other, user.Message);
}
finally
{
Directory.Delete(cwd, recursive: true);
}
}
[Fact]
public async Task Doctor_command_sends_a_report_without_triggering_a_turn()
{
var api = new FakeExtensionApi();
var ui = new RecordingUi();
await new ClaudeCodeProviderExtension(new ClaudeCodeProviderOptions { ClaudePath = null, BridgeDllPath = null, DotnetPath = null, Registry = new SessionRegistry(), GetEnv = _ => null }).ActivateAsync(api, CancellationToken.None);
await api.Commands["claude-code-doctor"].Handler("", new FakeContext(Path.GetTempPath(), "s", ui), CancellationToken.None);
var (message, delivery, triggerTurn) = Assert.Single(api.Sent);
Assert.Equal("claude-code-doctor", message.CustomType);
Assert.Equal(MessageDelivery.Steer, delivery);
Assert.False(triggerTurn);
Assert.StartsWith("# Claude Code doctor", message.Text);
Assert.Contains("Claude Code: **not found**", message.Text);
// The bridge and dotnet are auto-located next to the test host, so the probe itself is reported (ok or broken).
Assert.Contains("- Bridge: ", message.Text);
Assert.Contains("Problems found", message.Text);
var notice = Assert.Single(ui.Notices);
Assert.Equal(NotifyKind.Warning, notice.Kind);
}
}