From f9403aed777e1ef9de38314a0206b6558f7b46b4 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 25 Sep 2026 13:13:38 +0000 Subject: [PATCH] feat(claude-code): provider extension and mcp proposal bridge --- .../Program.cs | 216 ++++- .../ClaudeArgs.cs | 248 ++++++ .../ClaudeCodeApi.cs | 314 ++++++++ .../ClaudeCodeException.cs | 36 + .../ClaudeCodeModels.cs | 58 ++ .../ClaudeCodeProviderExtension.cs | 99 +++ .../ClaudeImageStore.cs | 116 +++ .../ClaudeInstallation.cs | 230 ++++++ .../ClaudeProcess.cs | 335 ++++++++ .../ContextSerializer.cs | 231 ++++++ .../DoctorCommand.cs | 190 +++++ .../JsonlReader.cs | 79 ++ .../SessionRegistry.cs | 155 ++++ .../StreamEventMapper.cs | 750 ++++++++++++++++++ .../ToolCatalog.cs | 145 ++++ .../ClaudeCode/BridgeTests.cs | 187 +++++ .../ClaudeCode/ClaudeArgsTests.cs | 131 +++ .../ClaudeCode/ClaudeCodeApiTests.cs | 221 ++++++ .../ClaudeCode/ClaudeInstallationTests.cs | 109 +++ .../ClaudeCode/ClaudeProcessTests.cs | 78 ++ .../ClaudeCode/ContextSerializerTests.cs | 216 +++++ .../ClaudeCode/DoctorCommandTests.cs | 62 ++ .../ClaudeCode/Fixtures.cs | 42 + .../ClaudeCode/JsonlReaderTests.cs | 83 ++ .../ClaudeCode/ProviderExtensionTests.cs | 193 +++++ .../ClaudeCode/SessionRegistryTests.cs | 151 ++++ .../ClaudeCode/StreamEventMapperTests.cs | 356 +++++++++ .../ClaudeCode/ToolCatalogTests.cs | 117 +++ 28 files changed, 5147 insertions(+), 1 deletion(-) create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeModels.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeImageStore.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/DoctorCommand.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/JsonlReader.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/SessionRegistry.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/BridgeTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeInstallationTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeProcessTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/DoctorCommandTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/JsonlReaderTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/SessionRegistryTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ToolCatalogTests.cs diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs index 51bb64e..1cb88f5 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Program.cs @@ -1 +1,215 @@ -return 0; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge; + +/// +/// Proposal-only MCP stdio server. It lists the tool catalogue the provider wrote for it and refuses +/// every tools/call: Claude Code's dontAsk mode denies the call itself, and the provider +/// captures the model's tool_use block as a proposal for the core to execute. +/// Line-delimited JSON-RPC 2.0 over stdio; a request over is fatal so +/// rejected framing state is never reused. +/// +public static class Bridge +{ + public const int MaxRequestBytes = 1024 * 1024; + public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG"; + public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY"; + public const string ViolationVariable = "VOLCANIC_CLAUDE_TOOL_VIOLATION"; + public const string ServerName = "volcanic-tool-proposals"; + public const string ServerVersion = "1.0.0"; + public const string ProtocolVersion = "2025-11-25"; + public const string ExecutionRefused = "Security invariant: this server proposes tools but never executes them"; + + public static int Main() + { + var env = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + env[(string)entry.Key] = entry.Value as string; + var utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + using var input = new StreamReader(Console.OpenStandardInput(), utf8, detectEncodingFromByteOrderMarks: false); + using var output = new StreamWriter(Console.OpenStandardOutput(), utf8) { AutoFlush = true, NewLine = "\n" }; + using var error = new StreamWriter(Console.OpenStandardError(), utf8) { AutoFlush = true, NewLine = "\n" }; + return Run(input, output, error, env); + } + + /// Serves requests from until EOF. Returns 0 on EOF, 1 on a fatal request, 2 on a startup failure. + public static int Run(TextReader input, TextWriter output, TextWriter error, IReadOnlyDictionary env) + { + if (!env.TryGetValue(CatalogVariable, out var catalogPath) || string.IsNullOrWhiteSpace(catalogPath)) + return FailStartup(error, $"{CatalogVariable} is required"); + + JsonArray tools; + try + { + var node = JsonNode.Parse(File.ReadAllText(catalogPath)); + tools = node as JsonArray ?? throw new InvalidDataException("catalog must be an array"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException or NotSupportedException) + { + return FailStartup(error, $"invalid tool catalog: {ex.Message}"); + } + + env.TryGetValue(ReadyVariable, out var readyPath); + env.TryGetValue(ViolationVariable, out var violationPath); + var server = new Server(output, tools, readyPath, violationPath); + + try + { + var buffer = new char[8192]; + var pending = new StringBuilder(); + while (true) + { + var read = input.Read(buffer, 0, buffer.Length); + if (read <= 0) break; + var start = 0; + for (var i = 0; i < read; i++) + { + if (buffer[i] != '\n') continue; + pending.Append(buffer, start, i - start); + start = i + 1; + if (!server.AcceptLine(TakeLine(pending))) return 1; + } + pending.Append(buffer, start, read - start); + // Every char is at least one byte, so this many chars can never fit the byte bound (one CR allowed). + if (pending.Length > MaxRequestBytes + 1) + { + server.FailOversized(); + return 1; + } + } + if (pending.Length > 0 && !server.AcceptLine(TakeLine(pending))) return 1; + return 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + error.WriteLine($"MCP bridge failure: {ex.Message}"); + error.Flush(); + return 1; + } + } + + private static string TakeLine(StringBuilder pending) + { + if (pending.Length > 0 && pending[^1] == '\r') pending.Length--; + var line = pending.ToString(); + pending.Clear(); + return line; + } + + private static int FailStartup(TextWriter error, string message) + { + error.WriteLine(message); + error.Flush(); + return 2; + } + + private sealed class Server(TextWriter output, JsonArray tools, string? readyPath, string? violationPath) + { + private static readonly JsonSerializerOptions WireOptions = new(JsonSerializerDefaults.General); + + /// Handles one line. Returns false when the request was oversized and the server must stop. + public bool AcceptLine(string line) + { + if (Encoding.UTF8.GetByteCount(line) > MaxRequestBytes) + { + FailOversized(); + return false; + } + if (string.IsNullOrWhiteSpace(line)) return true; + + JsonNode? message; + try + { + message = JsonNode.Parse(line); + } + catch (JsonException) + { + SendError(null, -32700, "Invalid JSON-RPC request"); + return true; + } + if (message is not JsonObject request) + { + SendError(null, -32600, "Invalid JSON-RPC request"); + return true; + } + + var hasId = request.ContainsKey("id"); + var id = request["id"]; + var method = request["method"] is JsonValue value && value.TryGetValue(out var m) ? m : null; + + switch (method) + { + case "initialize": + if (hasId) + { + Send(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id?.DeepClone(), + ["result"] = new JsonObject + { + ["protocolVersion"] = ProtocolVersion, + ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() }, + ["serverInfo"] = new JsonObject { ["name"] = ServerName, ["version"] = ServerVersion }, + }, + }); + } + return true; + case "tools/list": + if (hasId) + { + Send(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id?.DeepClone(), + ["result"] = new JsonObject { ["tools"] = tools.DeepClone() }, + }); + } + Mark(readyPath, "ready\n"); + return true; + case "tools/call": + Mark(violationPath, "tools/call reached proposal-only MCP server\n"); + if (hasId) SendError(id, -32001, ExecutionRefused); + return true; + default: + // Notifications (no id) such as notifications/initialized are ignored. + if (hasId) SendError(id, -32601, $"Method not found: {method ?? "null"}"); + return true; + } + } + + public void FailOversized() => SendError(null, -32700, "MCP request exceeds the bridge limit"); + + private void SendError(JsonNode? id, int code, string message) => Send(new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id?.DeepClone(), + ["error"] = new JsonObject { ["code"] = code, ["message"] = message }, + }); + + private void Send(JsonObject response) + { + output.Write(response.ToJsonString(WireOptions)); + output.Write('\n'); + output.Flush(); + } + + private static void Mark(string? path, string contents) + { + if (string.IsNullOrEmpty(path)) return; + try + { + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + using var stream = new FileStream(path, options); + stream.Write(Encoding.UTF8.GetBytes(contents)); + } + catch (IOException) when (File.Exists(path)) + { + // Marker already written by an earlier request: keep the first one. + } + } + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs new file mode 100644 index 0000000..7217e13 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs @@ -0,0 +1,248 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// Everything the argv builder needs for one request. +/// The alias passed to --model. +/// The --effort level, or null to omit the flag. +/// File handed to --system-prompt-file. +/// Tool catalogue for the bridge; null when the request carries no tools (empty MCP config). +/// Marker the bridge creates on a tools/call. +/// Marker the bridge creates after tools/list. +/// The bridge assembly dotnet runs. +/// The dotnet muxer. +/// The MCP server name; tools are exposed as mcp__<ServerName>__<alias>. +public sealed record ClaudeArgsInput( + string Model, + string? Effort, + string SystemPromptPath, + string? CatalogPath = null, + string? ViolationPath = null, + string? ReadyPath = null, + string? BridgeDllPath = null, + string? DotnetPath = null, + string ServerName = ToolCatalog.DefaultServerName); + +/// Builds the exact claude argv and the allow-listed child environment. +public static class ClaudeArgs +{ + // Empty setting sources plus explicit settings preserve subscription authentication while suppressing user and + // project customisations. --bare would disable OAuth, and --safe-mode would disable the proposal MCP server. + // totalTokensReminder would otherwise append a changing reminder that breaks append-only cache reuse, and the + // built-in agents-md plugin loads regardless of setting sources unless disabled here. + public const string Settings = "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}"; + public const string EmptyMcpConfig = "{\"mcpServers\":{}}"; + + public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG"; + public const string ViolationVariable = "VOLCANIC_CLAUDE_TOOL_VIOLATION"; + public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY"; + public const string MaxOutputTokensVariable = "CLAUDE_CODE_MAX_OUTPUT_TOKENS"; + + private static readonly JsonSerializerOptions ConfigOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + /// Variables forwarded from the parent environment (compared case-insensitively). LC_* is forwarded as a prefix. + public static readonly IReadOnlyList AllowedVariables = + [ + "HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "TMPDIR", "TMP", "TEMP", + // A relocated Claude Code configuration directory holds the login to use. + "CLAUDE_CONFIG_DIR", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + // The extra CA bundle a TLS-inspecting proxy requires. + "NODE_EXTRA_CA_CERTS", + "SystemRoot", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "ComSpec", "PATHEXT", + ]; + + /// Variables that must never reach a Claude child: this provider is subscription-only. + public static readonly IReadOnlyList DeniedVariables = + [ + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", + ]; + + /// The complete argv (without the executable), in the fixed order Claude Code is validated against. + public static IReadOnlyList Build(ClaudeArgsInput input) + { + ArgumentNullException.ThrowIfNull(input); + if (string.IsNullOrWhiteSpace(input.Model)) throw new ArgumentException("A model alias is required.", nameof(input)); + if (string.IsNullOrWhiteSpace(input.SystemPromptPath)) throw new ArgumentException("A system prompt file is required.", nameof(input)); + + var args = new List + { + "-p", + "--setting-sources", "", + "--settings", Settings, + "--disable-slash-commands", + "--strict-mcp-config", + "--permission-mode", "dontAsk", + "--no-chrome", + "--no-session-persistence", + "--prompt-suggestions", "false", + "--mcp-config", BuildMcpConfig(input), + "--tools", "", + "--model", input.Model, + }; + if (input.Effort is not null) + { + args.Add("--effort"); + args.Add(input.Effort); + } + // Claude 5 and Opus 4.7+ return thinking blocks whose text is empty unless summarized display is requested. + args.Add("--thinking-display"); + args.Add("summarized"); + args.Add("--input-format"); + args.Add("stream-json"); + args.Add("--output-format"); + args.Add("stream-json"); + args.Add("--include-partial-messages"); + args.Add("--verbose"); + // The system prompt stays out of the argv, away from command-line size limits. + args.Add("--system-prompt-file"); + args.Add(input.SystemPromptPath); + return args; + } + + /// The --mcp-config JSON: the bridge as the only server, or no servers at all without tools. + public static string BuildMcpConfig(ClaudeArgsInput input) + { + ArgumentNullException.ThrowIfNull(input); + if (input.CatalogPath is null) return EmptyMcpConfig; + if (string.IsNullOrWhiteSpace(input.BridgeDllPath)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The proposal MCP bridge assembly was not located; tools cannot be exposed to Claude Code"); + if (string.IsNullOrWhiteSpace(input.DotnetPath)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The dotnet muxer was not located; the proposal MCP bridge cannot be launched"); + var env = new JsonObject { [CatalogVariable] = input.CatalogPath }; + if (input.ViolationPath is not null) env[ViolationVariable] = input.ViolationPath; + if (input.ReadyPath is not null) env[ReadyVariable] = input.ReadyPath; + var config = new JsonObject + { + ["mcpServers"] = new JsonObject + { + [input.ServerName] = new JsonObject + { + ["command"] = input.DotnetPath, + ["args"] = new JsonArray(input.BridgeDllPath), + ["env"] = env, + }, + }, + }; + return config.ToJsonString(ConfigOptions); + } + + /// The name of a denied variable present in , or null. + public static string? FindDeniedVariable(IReadOnlyDictionary parentEnv) + { + ArgumentNullException.ThrowIfNull(parentEnv); + foreach (var (name, value) in parentEnv) + { + if (value is null) continue; + if (DeniedVariables.Any(denied => string.Equals(denied, name, StringComparison.OrdinalIgnoreCase))) return name; + } + return null; + } + + /// + /// The complete environment a Claude child receives: the allow-listed parent variables plus the pinned ones. + /// Throws auth when the parent process carries API-key or third-party-provider authentication. + /// + public static IReadOnlyDictionary BuildEnvironment(IReadOnlyDictionary parentEnv, int maxOutputTokens) + { + ArgumentNullException.ThrowIfNull(parentEnv); + if (FindDeniedVariable(parentEnv) is { } denied) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, + $"{denied} is set; the Claude Code provider only supports subscription authentication and never forwards API-key or third-party credentials. Unset it to use this provider"); + return AllowListed(parentEnv, maxOutputTokens); + } + + /// The allow-listed environment without the denial check (used by diagnostics that only probe the CLI). + public static IReadOnlyDictionary AllowListed(IReadOnlyDictionary parentEnv, int? maxOutputTokens) + { + ArgumentNullException.ThrowIfNull(parentEnv); + var env = new Dictionary(StringComparer.Ordinal); + foreach (var (name, value) in parentEnv) + { + if (value is null) continue; + var allowed = name.StartsWith("LC_", StringComparison.Ordinal) + || AllowedVariables.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase)); + if (allowed) env[name] = value; + } + env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1"; + // Claude Code collects git status, log and user.name at startup for a default system prompt this provider + // replaces. Run in the user's project, that status can execute a configured git clean filter. + env["CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS"] = "1"; + env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"; + // The core owns compaction. Given a transcript near its limit, Claude Code would otherwise compact it itself. + env["DISABLE_COMPACT"] = "1"; + if (maxOutputTokens is { } tokens) env[MaxOutputTokensVariable] = tokens.ToString(System.Globalization.CultureInfo.InvariantCulture); + return env; + } + + /// The current process environment as a dictionary. + public static IReadOnlyDictionary CurrentEnvironment() + { + var env = new Dictionary(StringComparer.Ordinal); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + env[(string)entry.Key] = entry.Value as string; + return env; + } +} + +/// Finds the bridge assembly and the dotnet muxer that runs it. +public static class BridgeLocator +{ + public const string BridgeFileName = "Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll"; + + /// The bridge dll next to the host, else next to this assembly; null when neither exists. + public static string? FindBridgeDll() + { + var candidates = new List { Path.Combine(AppContext.BaseDirectory, BridgeFileName) }; + var assemblyDirectory = SafeDirectory(typeof(BridgeLocator).Assembly.Location); + if (assemblyDirectory is not null) candidates.Add(Path.Combine(assemblyDirectory, BridgeFileName)); + return candidates.FirstOrDefault(File.Exists); + } + + /// The dotnet muxer: the current process when it is dotnet, else dotnet on PATH, else DOTNET_ROOT/dotnet. + public static string? FindDotnet(Func getEnv) + { + ArgumentNullException.ThrowIfNull(getEnv); + var processPath = Environment.ProcessPath; + if (processPath is not null) + { + var name = Path.GetFileNameWithoutExtension(processPath); + if (string.Equals(name, "dotnet", StringComparison.OrdinalIgnoreCase) && File.Exists(processPath)) return processPath; + } + var names = OperatingSystem.IsWindows() ? new[] { "dotnet.exe" } : new[] { "dotnet" }; + foreach (var directory in (getEnv("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + foreach (var name in names) + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) return candidate; + } + } + var root = getEnv("DOTNET_ROOT"); + if (!string.IsNullOrWhiteSpace(root)) + { + foreach (var name in names) + { + var candidate = Path.Combine(root, name); + if (File.Exists(candidate)) return candidate; + } + } + return null; + } + + private static string? SafeDirectory(string location) + { + if (string.IsNullOrEmpty(location)) return null; + try + { + return Path.GetDirectoryName(location); + } + catch (ArgumentException) + { + return null; + } + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs new file mode 100644 index 0000000..cb2bafd --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs @@ -0,0 +1,314 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Abstractions.Ui; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// Where the CLI, the bridge and the muxer live, plus environment access (overridable for tests). +public sealed record ClaudeCodeApiOptions +{ + /// The claude executable; null when it was not located (every request then fails with executable_missing). + public string? ClaudePath { get; init; } + + /// The proposal bridge assembly; null when it was not located (tool-bearing requests then fail with mcp_startup). + public string? BridgeDllPath { get; init; } + + /// The dotnet muxer that runs the bridge. + public string? DotnetPath { get; init; } + + /// Environment lookup for the timeout settings. + public Func GetEnv { get; init; } = Environment.GetEnvironmentVariable; + + /// The environment the allow-list is applied to; the process environment when null. + public IReadOnlyDictionary? ParentEnvironment { get; init; } + + /// The MCP server name the bridge is registered under (tools become mcp__<name>__<alias>). + public string ServerName { get; init; } = ToolCatalog.DefaultServerName; +} + +/// +/// The claude-code-headless API: fully stateless. Every request spawns a fresh claude -p, replays the whole +/// transcript as one stream-json user message, exposes the active tools through the proposal bridge, captures the +/// model's tool_use as a proposal (terminating Claude at the stop), and never throws out of . +/// +public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions options) +{ + public const string McpReadyTimeoutVariable = "VOLCANIC_CLAUDE_CODE_MCP_READY_TIMEOUT_MS"; + public static readonly TimeSpan DefaultMcpReadyTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ReadyPollInterval = TimeSpan.FromMilliseconds(25); + private static readonly TimeSpan ExitGrace = TimeSpan.FromSeconds(10); + private static readonly IReadOnlyDictionary NoHeaders = new Dictionary(); + private static readonly JsonSerializerOptions StdinOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + public SessionRegistry Registry { get; } = registry ?? throw new ArgumentNullException(nameof(registry)); + public ClaudeCodeApiOptions Options { get; } = options ?? throw new ArgumentNullException(nameof(options)); + + public async IAsyncEnumerable StreamAsync( + Model model, + LlmContext context, + StreamOptions streamOptions, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); + // Cancelled when the consumer stops enumerating early, so an abandoned request never keeps Claude alive. + using var consumerGone = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var worker = RunRequestAsync(model, context, streamOptions ?? StreamOptions.Default, channel.Writer, consumerGone.Token); + try + { + await foreach (var e in channel.Reader.ReadAllAsync(CancellationToken.None).ConfigureAwait(false)) + yield return e; + } + finally + { + consumerGone.Cancel(); + await worker.ConfigureAwait(false); + } + } + + private async Task RunRequestAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter writer, CancellationToken ct) + { + var builder = new AssistantMessageBuilder(model); + StreamEventMapper? mapper = null; + ClaudeProcess? process = null; + string? tempDir = null; + string? violationPath = null; + Action? notify = null; + var terminatedForHandoff = false; + var responseAnnounced = false; + try + { + ct.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(context); + + var claudePath = Options.ClaudePath; + if (string.IsNullOrEmpty(claudePath) || !File.Exists(claudePath)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing, + claudePath is null + ? $"Claude Code (claude) was not found on PATH; install it or set {ClaudeInstallation.PathVariable}" + : $"Claude Code executable was not found: {claudePath}"); + + // Phase 1: resolve the session and prepare the private request state. + var hasTools = context.Tools.Count > 0; + var (cwd, images, _) = Registry.Resolve(streamOptions.SessionId, context, hasTools); + notify = streamOptions.SessionId is null ? null : Registry.Get(streamOptions.SessionId)?.Notify; + var catalog = new ToolCatalog(context.Tools, Options.ServerName); + mapper = new StreamEventMapper(model, catalog, builder); + var effort = ClaudeCodeModels.EffortFor(model, streamOptions.Reasoning); + var maxOutputTokens = Math.Min(streamOptions.MaxTokens ?? model.MaxTokens, model.MaxTokens); + if (maxOutputTokens <= 0) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, "maxTokens must be a positive integer"); + + tempDir = Directory.CreateTempSubdirectory("volcanic-claude-").FullName; + var systemPromptPath = Path.Combine(tempDir, "system-prompt.txt"); + await File.WriteAllTextAsync(systemPromptPath, context.SystemPrompt ?? "", Utf8NoBom, ct).ConfigureAwait(false); + string? catalogPath = null; + string? readyPath = null; + if (!catalog.IsEmpty) + { + if (string.IsNullOrEmpty(Options.BridgeDllPath) || !File.Exists(Options.BridgeDllPath)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, + $"The proposal MCP bridge ({BridgeLocator.BridgeFileName}) was not found next to the extension; tools cannot be exposed to Claude Code"); + if (string.IsNullOrEmpty(Options.DotnetPath)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The dotnet muxer was not located; the proposal MCP bridge cannot be launched"); + catalogPath = Path.Combine(tempDir, "tools.json"); + await File.WriteAllTextAsync(catalogPath, catalog.ToJson() + "\n", Utf8NoBom, ct).ConfigureAwait(false); + readyPath = Path.Combine(tempDir, "mcp-ready"); + violationPath = Path.Combine(tempDir, "mcp-violation"); + } + + var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None); + var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName)); + var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens); + + if (streamOptions.OnPayload is not null) + { + var payload = new JsonObject + { + ["provider"] = ClaudeCodeModels.ProviderId, + ["model"] = model.Id, + ["cwd"] = cwd, + ["argv"] = new JsonArray(argv.Select(a => (JsonNode)JsonValue.Create(a)).ToArray()), + ["stdin"] = stdin.DeepClone(), + }; + var replacement = await streamOptions.OnPayload(payload).ConfigureAwait(false); + // Only the stdin message can be replaced: argv and the working directory are the transport's own. + if (replacement is JsonObject replaced && replaced["stdin"] is JsonObject replacedStdin) + stdin = (JsonObject)replacedStdin.DeepClone(); + } + var stdinLine = stdin.ToJsonString(StdinOptions); + + var totalTimeout = ClaudeProcess.TimeoutSetting(Options.GetEnv, ClaudeProcess.TotalTimeoutVariable, ClaudeProcess.DefaultTotalTimeout); + if (streamOptions.Timeout is { } requested && requested > TimeSpan.Zero && requested < totalTimeout) totalTimeout = requested; + var idleTimeout = Min(ClaudeProcess.TimeoutSetting(Options.GetEnv, ClaudeProcess.IdleTimeoutVariable, ClaudeProcess.DefaultIdleTimeout), totalTimeout); + var readyTimeout = Min(ClaudeProcess.TimeoutSetting(Options.GetEnv, McpReadyTimeoutVariable, DefaultMcpReadyTimeout), totalTimeout); + + // Phase 2: spawn Claude in the session directory. + ct.ThrowIfCancellationRequested(); + process = ClaudeProcess.Start(new ClaudeProcessOptions(claudePath, argv, cwd, env, idleTimeout, totalTimeout)); + var claude = process; + using var abortRegistration = ct.Register(() => _ = claude.TerminateAsync()); + + if (readyPath is not null) + await WaitForReadyAsync(readyPath, readyTimeout, process, argv, ct).ConfigureAwait(false); + await process.WriteStdinAsync(stdinLine, ct).ConfigureAwait(false); + + // Phase 3: consume and validate the ordered JSONL protocol. + await foreach (var record in JsonlReader.ReadAsync(process.Stdout, cancellationToken: ct).ConfigureAwait(false)) + { + process.Touch(); + if (streamOptions.OnProviderStreamEvent is not null) await streamOptions.OnProviderStreamEvent(record).ConfigureAwait(false); + var events = mapper.Accept(record); + if (!responseAnnounced && mapper.SawInit) + { + // A validated initialization is this transport's analogue of a received response; announce it before + // any content so after-response observers run ahead of the Start event. + responseAnnounced = true; + if (streamOptions.OnResponse is not null) await streamOptions.OnResponse(new ProviderResponseInfo(200, NoHeaders)).ConfigureAwait(false); + } + foreach (var e in events) await writer.WriteAsync(e, CancellationToken.None).ConfigureAwait(false); + if (mapper.HandoffLatched && !terminatedForHandoff) + { + // The proposal (or the length stop) is complete; Claude Code would now run a turn of its own. + terminatedForHandoff = true; + _ = process.TerminateAsync(); + } + if (mapper.SawResult) break; + } + + // Phase 4: validate the exit and private state before publishing success. + var exitCode = await WaitForExitAsync(process, ct).ConfigureAwait(false); + if (process.TimeoutError is { } timeout) throw timeout; + ct.ThrowIfCancellationRequested(); + if (violationPath is not null && File.Exists(violationPath)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpExecution, "Security invariant violated: Claude Code attempted to execute a proposal tool internally"); + if (mapper.DeferredError is { } deferred) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolStream, deferred); + + AssistantMessageEvent done; + if (mapper.HandoffLatched) + { + // The provider terminated Claude on purpose: 143 (SIGTERM), 137 (SIGKILL), or whatever a late exit reports. + done = mapper.Complete(); + } + else if (mapper.HasSuccessfulResult) + { + if (exitCode != 0) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, $"Claude Code exited after a successful result (code {exitCode}){StderrDetail(process, tempDir)}"); + done = mapper.Complete(); + } + else + { + // A rejected rate limit explains the missing terminal better than the exit code (and the core retries it). + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProcessExit, + mapper.RateLimitFailure ?? $"Claude Code exited before a terminal event (code {exitCode}){StderrDetail(process, tempDir)}"); + } + + Cleanup(ref tempDir); + await writer.WriteAsync(done, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + var aborted = ct.IsCancellationRequested || ex is OperationCanceledException; + if (process is not null) await process.TerminateAsync().ConfigureAwait(false); + var failure = builder.IsFinished + ? null + : aborted ? builder.Fail("aborted", aborted: true) : builder.Fail(FormatFailure(ex)); + if (failure is not null) await writer.WriteAsync(failure, CancellationToken.None).ConfigureAwait(false); + } + finally + { + process?.Dispose(); + Cleanup(ref tempDir); + if (mapper is not null && notify is not null) + { + foreach (var notice in mapper.Notices) + { + try { notify(notice, NotifyKind.Warning); } catch (Exception) { /* notifications are advisory */ } + } + } + writer.TryComplete(); + } + } + + private static async Task WaitForReadyAsync(string readyPath, TimeSpan timeout, ClaudeProcess process, IReadOnlyList argv, CancellationToken ct) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + if (File.Exists(readyPath)) return; + if (process.HasExited) + { + var code = await process.WaitForExitAsync(ct).ConfigureAwait(false); + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, + $"Claude Code exited before the proposal MCP server became ready (code {code}){ReadyDiagnostics(process, argv)}"); + } + await Task.Delay(ReadyPollInterval, ct).ConfigureAwait(false); + } + if (File.Exists(readyPath)) return; + // Name the bridge launch: this timeout is far more often an unlaunchable bridge than a slow one. + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, + $"The proposal MCP server did not become ready within {timeout.TotalMilliseconds:0}ms{ReadyDiagnostics(process, argv)}"); + } + + private static string ReadyDiagnostics(ClaudeProcess process, IReadOnlyList argv) + { + var mcpIndex = argv.ToList().IndexOf("--mcp-config"); + var command = mcpIndex >= 0 && mcpIndex + 1 < argv.Count ? $"; Claude Code was told to launch the bridge from --mcp-config {argv[mcpIndex + 1]}" : ""; + var stderr = process.StderrExcerpt(); + return $"{command}{(stderr.Length > 0 ? $"; Claude Code stderr: {stderr}" : "")}; run /{DoctorCommand.Name} to complete the handshake directly"; + } + + private static async Task WaitForExitAsync(ClaudeProcess process, CancellationToken ct) + { + using var grace = CancellationTokenSource.CreateLinkedTokenSource(ct); + grace.CancelAfter(ExitGrace); + try + { + return await process.WaitForExitAsync(grace.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // stdout closed but the process lingers: terminate the tree and settle. + await process.TerminateAsync().ConfigureAwait(false); + return await process.WaitForExitAsync(ct).ConfigureAwait(false); + } + } + + private static string StderrDetail(ClaudeProcess process, string? tempDir) + { + var excerpt = process.StderrExcerpt(privatePaths: tempDir is null ? null : [tempDir]); + return excerpt.Length == 0 ? "" : $": {excerpt}"; + } + + private static string FormatFailure(Exception ex) => ex switch + { + ClaudeCodeException claude => claude.FormatForUser(), + _ => $"[provider] {ex.GetType().Name}: {ex.Message}", + }; + + private static void Cleanup(ref string? tempDir) + { + if (tempDir is null) return; + var directory = tempDir; + tempDir = null; + try + { + Directory.Delete(directory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort: a stale request directory under the temp root is harmless. + } + } + + private static TimeSpan Min(TimeSpan left, TimeSpan right) => left < right ? left : right; +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs new file mode 100644 index 0000000..17758e9 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs @@ -0,0 +1,36 @@ +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// A typed failure of the Claude Code transport. is stable and machine-readable. +public sealed class ClaudeCodeException(string code, string message, bool retryable = false) : Exception(message) +{ + public string Code { get; } = code; + + /// True when repeating the request from the unchanged context can succeed (a mid-stream interruption). + public bool Retryable { get; } = retryable; + + /// + /// The message the provider publishes: [code] message, except that a context overflow keeps its + /// context_length_exceeded: prefix intact because the core's retry policy keys on it. + /// + public string FormatForUser() => Code == Codes.ContextOverflow ? Message : $"[{Code}] {Message}"; + + public static class Codes + { + public const string ExecutableMissing = "executable_missing"; + public const string Auth = "auth"; + public const string ProtocolInit = "protocol_init"; + public const string ProtocolStream = "protocol_stream"; + public const string McpStartup = "mcp_startup"; + public const string McpExecution = "mcp_execution"; + public const string ToolUnknown = "tool_unknown"; + public const string StreamInterrupted = "stream_interrupted"; + public const string WorkingDirectory = "working_directory"; + public const string ProcessExit = "process_exit"; + public const string Timeout = "timeout"; + public const string ContextOverflow = "context_overflow"; + /// The context or tool declarations handed to the provider had a shape it cannot serialise. + public const string ContentShape = "content_shape"; + /// An image could not be attached (type, encoding, size, count or path). + public const string Image = "image"; + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeModels.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeModels.cs new file mode 100644 index 0000000..a2d31fb --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeModels.cs @@ -0,0 +1,58 @@ +using Volcanic.AgenticCore.Abstractions.Providers; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// +/// The model aliases Claude Code serves on a subscription. Cost is zero (the subscription pays), the +/// context windows are Claude Code's own, and MaxTokens is Claude Code's default output cap per alias. +/// +public static class ClaudeCodeModels +{ + public const string ProviderId = "claude-code"; + public const string ApiId = "claude-code-headless"; + public const string BaseUrl = "claude-code://local"; + public const string ProviderName = "Claude Code (subscription)"; + + /// Thinking level to --effort value. null omits the flag and keeps Claude Code's default. + public static readonly IReadOnlyDictionary EffortLevels = new Dictionary + { + [ThinkingLevel.Off] = null, + [ThinkingLevel.Minimal] = null, + [ThinkingLevel.Low] = "low", + [ThinkingLevel.Medium] = "medium", + [ThinkingLevel.High] = "high", + [ThinkingLevel.XHigh] = "xhigh", + [ThinkingLevel.Max] = "max", + }; + + public static readonly Model Sonnet = Create("sonnet", "Claude Code Sonnet", 1_000_000, 64_000, reasoning: true); + public static readonly Model Opus = Create("opus", "Claude Code Opus", 1_000_000, 128_000, reasoning: true); + // Haiku has no effort control; Claude Code still owns its thinking default. + public static readonly Model Haiku = Create("haiku", "Claude Code Haiku", 200_000, 32_000, reasoning: false); + public static readonly Model Fable = Create("fable", "Claude Code Fable", 1_000_000, 64_000, reasoning: true); + + public static IReadOnlyList All { get; } = [Sonnet, Opus, Haiku, Fable]; + + /// The --effort value for a request, or null to omit the flag. + public static string? EffortFor(Model model, ThinkingLevel? level) + { + if (!model.Reasoning || level is null) return null; + var map = model.ThinkingLevelMap ?? EffortLevels; + return map.TryGetValue(level.Value, out var effort) ? effort : null; + } + + private static Model Create(string id, string name, int contextWindow, int maxTokens, bool reasoning) => new() + { + Id = id, + Name = name, + Provider = ProviderId, + Api = ApiId, + BaseUrl = BaseUrl, + Reasoning = reasoning, + Input = [ModelInput.Text, ModelInput.Image], + Cost = ModelCost.Zero, + ContextWindow = contextWindow, + MaxTokens = maxTokens, + ThinkingLevelMap = reasoning ? EffortLevels : null, + }; +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs new file mode 100644 index 0000000..9fb1a45 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeProviderExtension.cs @@ -0,0 +1,99 @@ +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.Ui; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// Host overrides for the extension; everything is located automatically when null. +public sealed record ClaudeCodeProviderOptions +{ + public string? ClaudePath { get; init; } + public string? BridgeDllPath { get; init; } + public string? DotnetPath { get; init; } + public Func? GetEnv { get; init; } + /// The registry to use; the process-wide when null. + public SessionRegistry? Registry { get; init; } + public string ServerName { get; init; } = ToolCatalog.DefaultServerName; +} + +/// +/// Registers the claude-code provider (driving the local claude CLI on a subscription), the +/// /claude-code-doctor command, and the session bookkeeping the provider resolves working directories from. +/// The provider is registered even when the CLI is missing so the doctor can explain what to fix; requests then fail +/// with executable_missing. +/// +public sealed class ClaudeCodeProviderExtension(ClaudeCodeProviderOptions? options = null) : IExtension +{ + public const string ExtensionName = "claude-code-provider"; + + private readonly ClaudeCodeProviderOptions _options = options ?? new ClaudeCodeProviderOptions(); + private string? _ownSessionId; + + public string Name => ExtensionName; + + /// The API instance created during activation. + public ClaudeCodeApi? Api { get; private set; } + + public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(api); + var getEnv = _options.GetEnv ?? Environment.GetEnvironmentVariable; + var registry = _options.Registry ?? SessionRegistry.Shared; + var apiOptions = new ClaudeCodeApiOptions + { + ClaudePath = _options.ClaudePath ?? ClaudeInstallation.Locate(getEnv), + BridgeDllPath = _options.BridgeDllPath ?? BridgeLocator.FindBridgeDll(), + DotnetPath = _options.DotnetPath ?? BridgeLocator.FindDotnet(getEnv), + GetEnv = getEnv, + ServerName = _options.ServerName, + }; + var claude = new ClaudeCodeApi(registry, apiOptions); + Api = claude; + + api.RegisterProvider(new ProviderRegistration + { + Id = ClaudeCodeModels.ProviderId, + Name = ClaudeCodeModels.ProviderName, + Api = ClaudeCodeModels.ApiId, + BaseUrl = ClaudeCodeModels.BaseUrl, + RequiresApiKey = false, + Models = ClaudeCodeModels.All, + Stream = claude.StreamAsync, + }); + api.RegisterCommand(DoctorCommand.Name, DoctorCommand.Create(api, apiOptions, registry)); + + api.On((_, ctx, _) => + { + // Idempotent: a session can start twice without a shutdown between (reload, resume); drop the earlier id + // so the second registration does not orphan the first. + if (_ownSessionId is not null && _ownSessionId != ctx.SessionId) registry.Remove(_ownSessionId); + _ownSessionId = ctx.SessionId; + registry.Register(ctx.SessionId, ctx.Cwd, ctx.HasUi ? ctx.Ui.Notify : null); + if (apiOptions.ClaudePath is null && ctx.HasUi) + ctx.Ui.Notify($"[claude-code] Claude Code (claude) was not found; run /{DoctorCommand.Name} for details", NotifyKind.Warning); + return ValueTask.CompletedTask; + }); + + api.On((_, ctx, _) => + { + registry.Remove(ctx.SessionId); + if (_ownSessionId == ctx.SessionId) _ownSessionId = null; + return ValueTask.CompletedTask; + }); + + api.On((e, _, _) => + { + // Claude's own overflow wording is prefixed so the core compacts and retries instead of surfacing an error. + if (e.Message is AssistantMessage { StopReason: StopReason.Error, ErrorMessage: { } error } assistant) + { + var normalized = StreamEventMapper.NormalizeOverflowMessage(error); + if (normalized != error) e.Message = assistant with { ErrorMessage = normalized }; + } + return ValueTask.CompletedTask; + }); + + return ValueTask.CompletedTask; + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeImageStore.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeImageStore.cs new file mode 100644 index 0000000..e12e1d9 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeImageStore.cs @@ -0,0 +1,116 @@ +using System.Security.Cryptography; +using Volcanic.AgenticCore.Abstractions.Messages; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// An image written to the store: the file name referenced by image_attachment blocks and the absolute path Claude reads. +public sealed record ImageAttachment(string FileName, string AbsolutePath, int Bytes, bool Written); + +/// +/// Per-session, content-addressed image files (image-<sha256>.<ext>) that Claude Code reads through +/// quoted @"path" references. Paths stay stable across requests so the replayed transcript prefix stays cacheable. +/// +public sealed class ClaudeImageStore : IDisposable +{ + public const int MaxImageBytes = 20 * 1024 * 1024; + public const long MaxTotalImageBytes = 100L * 1024 * 1024; + public const int MaxImagesPerRequest = 20; + + private static readonly IReadOnlyDictionary Extensions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = "png", + ["image/jpeg"] = "jpg", + ["image/gif"] = "gif", + ["image/webp"] = "webp", + }; + + private readonly Lock _lock = new(); + private readonly string? _temporaryRoot; + private string? _directory; + private bool _disposed; + + /// Where the store directory is created; the system temp directory when null. + public ClaudeImageStore(string? temporaryRoot = null) + { + _temporaryRoot = temporaryRoot; + } + + /// The store directory, once the first image was written. + public string? Directory => _directory; + + public static bool IsSupportedMimeType(string mimeType) => Extensions.ContainsKey(mimeType); + + /// Validates and writes one image (a second copy of identical bytes is not rewritten). + public ImageAttachment Add(ImageContent image) + { + ArgumentNullException.ThrowIfNull(image); + if (string.IsNullOrEmpty(image.MimeType) || !Extensions.TryGetValue(image.MimeType, out var extension)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Unsupported image type: {image.MimeType}"); + byte[] bytes; + try + { + bytes = Convert.FromBase64String(image.Data ?? ""); + } + catch (FormatException) + { + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, "Image data is not valid base64"); + } + if (bytes.Length == 0 || bytes.Length > MaxImageBytes) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Image size must be between 1 byte and {MaxImageBytes} bytes"); + + var digest = Convert.ToHexStringLower(SHA256.HashData(bytes)); + var fileName = $"image-{digest}.{extension}"; + lock (_lock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var directory = EnsureDirectory(); + var path = Path.Combine(directory, fileName); + if (File.Exists(path)) + { + if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(bytes)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, "Image store contains mismatched image bytes"); + return new ImageAttachment(fileName, path, bytes.Length, Written: false); + } + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; + if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + using (var stream = new FileStream(path, options)) stream.Write(bytes); + return new ImageAttachment(fileName, path, bytes.Length, Written: true); + } + } + + private string EnsureDirectory() + { + if (_directory is not null) return _directory; + var root = Path.GetFullPath(_temporaryRoot ?? Path.GetTempPath()); + // Claude Code's quoted @-reference cannot contain a double quote. + if (root.Contains('"')) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, + $"Images cannot be attached from a temporary directory containing a double quote: {root}; choose another TMPDIR/TEMP"); + var directory = Path.Combine(root, $"volcanic-claude-images-{Guid.NewGuid():N}"); + System.IO.Directory.CreateDirectory(directory); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + _directory = directory; + return directory; + } + + public void Dispose() + { + string? directory; + lock (_lock) + { + if (_disposed) return; + _disposed = true; + directory = _directory; + _directory = null; + } + if (directory is null) return; + try + { + System.IO.Directory.Delete(directory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort: a stale image directory is harmless. + } + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs new file mode 100644 index 0000000..66a22df --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeInstallation.cs @@ -0,0 +1,230 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// What the doctor and the preflight learned about the installed claude. +public sealed record ClaudeInstallationInfo( + string Path, + string? Version, + bool LoggedIn, + string? AuthMethod, + string? SubscriptionType, + IReadOnlyList MissingFlags, + IReadOnlyList Warnings) +{ + /// firstParty for a claude.ai login. + public string? ApiProvider { get; init; } +} + +/// Result of one diagnostic run of the CLI. +public sealed record ProbeResult(int ExitCode, string Stdout, string Stderr, bool TimedOut); + +/// Locates and inspects the Claude Code CLI without ever sending it a prompt. +public static partial class ClaudeInstallation +{ + public const string PathVariable = "VOLCANIC_CLAUDE_CODE_PATH"; + public static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10); + + /// + /// Flags the headless launch relies on, checked against claude --help. --system-prompt-file is + /// deliberately represented by --system-prompt: the file variant is documented but hidden from the help screen. + /// + public static readonly IReadOnlyList RequiredFlags = + [ + "--print", "--setting-sources", "--settings", "--disable-slash-commands", "--permission-mode", "--no-chrome", + "--prompt-suggestions", "--output-format", "--input-format", "--include-partial-messages", "--verbose", + "--no-session-persistence", "--strict-mcp-config", "--mcp-config", "--tools", "--model", "--effort", "--system-prompt", + ]; + + /// VOLCANIC_CLAUDE_CODE_PATH, else claude on PATH (with PATHEXT suffixes on Windows); null when absent. + public static string? Locate(Func getEnv) + { + ArgumentNullException.ThrowIfNull(getEnv); + var configured = getEnv(PathVariable)?.Trim(); + if (!string.IsNullOrEmpty(configured)) return configured; + + var names = OperatingSystem.IsWindows() ? WindowsNames(getEnv("PATHEXT")) : ["claude"]; + foreach (var directory in (getEnv("PATH") ?? "").Split(System.IO.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + foreach (var name in names) + { + string candidate; + try + { + candidate = System.IO.Path.Combine(directory, name); + } + catch (ArgumentException) + { + continue; + } + if (File.Exists(candidate) && IsExecutable(candidate)) return candidate; + } + } + return null; + } + + /// Required flags that do not appear in the help text. + public static IReadOnlyList MissingFlags(string helpText) + { + ArgumentNullException.ThrowIfNull(helpText); + return RequiredFlags.Where(flag => !HasCliOption(helpText, flag)).ToList(); + } + + /// Runs --version, auth status and --help in parallel (10 s each) with the allow-listed environment. + public static async Task InspectAsync(string path, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!File.Exists(path)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing, $"Claude Code executable was not found: {path}"); + + var warnings = new List(); + var parent = ClaudeArgs.CurrentEnvironment(); + if (ClaudeArgs.FindDeniedVariable(parent) is { } denied) + warnings.Add($"{denied} is set in the environment; the provider never forwards it and refuses requests while it is set"); + var env = ClaudeArgs.AllowListed(parent, maxOutputTokens: null); + + var versionTask = RunAsync(path, ["--version"], env, ProbeTimeout, cancellationToken); + var authTask = RunAsync(path, ["auth", "status"], env, ProbeTimeout, cancellationToken); + var helpTask = RunAsync(path, ["--help"], env, ProbeTimeout, cancellationToken); + await Task.WhenAll(versionTask, authTask, helpTask).ConfigureAwait(false); + var version = await versionTask.ConfigureAwait(false); + var auth = await authTask.ConfigureAwait(false); + var help = await helpTask.ConfigureAwait(false); + + var versionText = VersionPattern().Match(version.Stdout + "\n" + version.Stderr) is { Success: true } match ? match.Value : null; + if (versionText is null) warnings.Add($"Could not determine the Claude Code version{Describe(version)}"); + + var loggedIn = false; + string? authMethod = null, apiProvider = null, subscription = null; + try + { + using var document = JsonDocument.Parse(ExtractJson(auth.Stdout)); + var root = document.RootElement; + loggedIn = root.TryGetProperty("loggedIn", out var l) && l.ValueKind == JsonValueKind.True; + authMethod = StringOrNull(root, "authMethod"); + apiProvider = StringOrNull(root, "apiProvider"); + subscription = StringOrNull(root, "subscriptionType")?.ToLowerInvariant(); + } + catch (JsonException) + { + warnings.Add($"claude auth status returned no JSON; treating the CLI as not logged in{Describe(auth)}"); + } + if (loggedIn && authMethod is not null && authMethod != "claude.ai") + warnings.Add($"Claude Code is logged in through '{authMethod}', not a claude.ai subscription; the provider requires subscription auth"); + + var missing = MissingFlags(help.Stdout + "\n" + help.Stderr); + if (help.ExitCode != 0 && string.IsNullOrWhiteSpace(help.Stdout)) + warnings.Add($"claude --help failed{Describe(help)}"); + + return new ClaudeInstallationInfo(path, versionText, loggedIn, authMethod, subscription, missing, warnings) { ApiProvider = apiProvider }; + } + + /// Runs the CLI with closed stdin and captured output. Throws executable_missing when it cannot start. + public static async Task RunAsync(string executable, IReadOnlyList arguments, IReadOnlyDictionary environment, TimeSpan timeout, CancellationToken cancellationToken) + { + var psi = new ProcessStartInfo(executable) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var argument in arguments) psi.ArgumentList.Add(argument); + psi.Environment.Clear(); + foreach (var (name, value) in environment) psi.Environment[name] = value; + + using var process = new Process { StartInfo = psi }; + try + { + process.Start(); + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) + { + throw new ClaudeCodeException(ClaudeCodeException.Codes.ExecutableMissing, $"Claude Code could not be started ({executable}): {ex.Message}"); + } + process.StandardInput.Close(); + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + var timedOut = false; + try + { + await process.WaitForExitAsync(cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + timedOut = true; + try { process.Kill(entireProcessTree: true); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { } + process.WaitForExit(2000); + } + if (cancellationToken.IsCancellationRequested) + { + try { process.Kill(entireProcessTree: true); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) { } + cancellationToken.ThrowIfCancellationRequested(); + } + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + return new ProbeResult(timedOut ? -1 : process.ExitCode, stdout, stderr, timedOut); + } + + private static bool HasCliOption(string helpText, string option) + { + var pattern = $@"(?:^|[\s,]){Regex.Escape(option)}(?=$|[\s,=<\[])"; + return Regex.IsMatch(helpText, pattern, RegexOptions.Multiline | RegexOptions.CultureInvariant); + } + + private static IReadOnlyList WindowsNames(string? pathext) + { + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) { ".exe", ".cmd", ".com" }; + var suffixes = (pathext ?? ".COM;.EXE;.BAT;.CMD") + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(allowed.Contains) + .Select(s => s.ToLowerInvariant()) + .Distinct() + .ToList(); + if (suffixes.Count == 0) suffixes = [".exe", ".cmd", ".com"]; + return suffixes.Select(s => $"claude{s}").ToList(); + } + + private static bool IsExecutable(string path) + { + if (OperatingSystem.IsWindows()) return true; + try + { + var mode = File.GetUnixFileMode(path); + return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } + + private static string ExtractJson(string stdout) + { + var trimmed = stdout.Trim(); + var start = trimmed.IndexOf('{'); + var end = trimmed.LastIndexOf('}'); + return start >= 0 && end > start ? trimmed[start..(end + 1)] : trimmed; + } + + private static string? StringOrNull(JsonElement root, string name) => + root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static string Describe(ProbeResult result) + { + if (result.TimedOut) return " (timed out)"; + var stderr = result.Stderr.Trim(); + var detail = stderr.Length > 200 ? stderr[^200..] : stderr; + return detail.Length == 0 ? $" (exit code {result.ExitCode})" : $" (exit code {result.ExitCode}: {detail})"; + } + + [GeneratedRegex(@"\d+\.\d+\.\d+")] + private static partial Regex VersionPattern(); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs new file mode 100644 index 0000000..40c91bc --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeProcess.cs @@ -0,0 +1,335 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// How to start one Claude Code child. +public sealed record ClaudeProcessOptions( + string Executable, + IReadOnlyList Arguments, + string WorkingDirectory, + IReadOnlyDictionary Environment, + TimeSpan IdleTimeout, + TimeSpan TotalTimeout); + +/// +/// A supervised claude child: replaced (allow-listed) environment, redirected stdio, a rolling stderr tail, an +/// idle timeout reset on every stdout record, a total timeout, and graceful termination (SIGTERM, then the whole tree). +/// +public sealed partial class ClaudeProcess : IDisposable +{ + public const int MaxStderrChars = 64 * 1024; + public const string IdleTimeoutVariable = "VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS"; + public const string TotalTimeoutVariable = "VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS"; + public static readonly TimeSpan DefaultIdleTimeout = TimeSpan.FromMinutes(5); + public static readonly TimeSpan DefaultTotalTimeout = TimeSpan.FromMinutes(30); + private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan KillWait = TimeSpan.FromSeconds(5); + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private const int SigTerm = 15; + + private readonly Process _process; + private readonly ClaudeProcessOptions _options; + private readonly StringBuilder _stderr = new(); + private readonly Task _stderrPump; + private readonly CancellationTokenSource _idle = new(); + private readonly CancellationTokenSource _total = new(); + private readonly Lock _lock = new(); + private Task? _termination; + private bool _disposed; + + private ClaudeProcess(Process process, ClaudeProcessOptions options) + { + _process = process; + _options = options; + _stderrPump = Task.Run(PumpStderrAsync); + _idle.Token.Register(() => OnTimeout($"Claude Code produced no protocol activity for {options.IdleTimeout.TotalMilliseconds:0}ms")); + _total.Token.Register(() => OnTimeout($"Claude Code request exceeded {options.TotalTimeout.TotalMilliseconds:0}ms")); + _idle.CancelAfter(options.IdleTimeout); + _total.CancelAfter(options.TotalTimeout); + } + + /// Starts the child. Throws executable_missing when it cannot be launched. + public static ClaudeProcess Start(ClaudeProcessOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var psi = new ProcessStartInfo(options.Executable) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = options.WorkingDirectory, + StandardInputEncoding = Utf8NoBom, + StandardErrorEncoding = Utf8NoBom, + }; + foreach (var argument in options.Arguments) psi.ArgumentList.Add(argument); + psi.Environment.Clear(); + foreach (var (name, value) in options.Environment) psi.Environment[name] = value; + + var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + try + { + process.Start(); + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) + { + process.Dispose(); + var missing = !File.Exists(options.Executable); + throw new ClaudeCodeException( + missing ? ClaudeCodeException.Codes.ExecutableMissing : ClaudeCodeException.Codes.ProcessExit, + missing ? $"Claude Code executable was not found: {options.Executable}" : $"Claude Code could not be started ({options.Executable}): {ex.Message}"); + } + return new ClaudeProcess(process, options); + } + + public int Pid => _process.Id; + + /// Raw stdout bytes (JSONL). + public Stream Stdout => _process.StandardOutput.BaseStream; + + public bool HasExited + { + get + { + try + { + return _process.HasExited; + } + catch (InvalidOperationException) + { + return true; + } + } + } + + /// Exit code; -1 while running. + public int ExitCode + { + get + { + try + { + return _process.HasExited ? _process.ExitCode : -1; + } + catch (InvalidOperationException) + { + return -1; + } + } + } + + /// True once was requested (by the provider, a timeout or cancellation). + public bool TerminationRequested { get; private set; } + + /// Set when the idle or total timeout terminated the child. + public ClaudeCodeException? TimeoutError { get; private set; } + + /// Resets the idle timeout; call on every stdout record. + public void Touch() + { + lock (_lock) + { + if (_disposed || _idle.IsCancellationRequested) return; + _idle.CancelAfter(_options.IdleTimeout); + } + } + + /// The tail of stderr fit for an error message: one line, bounded, private paths redacted. + public string StderrExcerpt(int maxChars = 1000, IEnumerable? privatePaths = null) + { + string raw; + lock (_stderr) raw = _stderr.ToString(); + var safe = ControlCharacters().Replace(raw, " ").Trim(); + foreach (var path in (privatePaths ?? []).Where(p => !string.IsNullOrEmpty(p)).OrderByDescending(p => p.Length)) + safe = safe.Replace(path, "", StringComparison.Ordinal); + return safe.Length <= maxChars ? safe : safe[^maxChars..].Trim(); + } + + /// Writes one line to stdin and closes it. A child that already exited is reported by the exit path, not here. + public async Task WriteStdinAsync(string line, CancellationToken cancellationToken) + { + try + { + var writer = _process.StandardInput; + await writer.WriteAsync(line.AsMemory(), cancellationToken).ConfigureAwait(false); + await writer.WriteAsync("\n".AsMemory(), cancellationToken).ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + writer.Close(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) + { + // Broken pipe: the child exited before reading; its exit code and stderr explain it. + } + } + + /// Waits for the child to exit and for its stderr to drain (bounded), then returns the exit code. + public async Task WaitForExitAsync(CancellationToken cancellationToken) + { + await _process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _stderrPump.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // A descendant still holds stderr; the tail collected so far is enough. + } + return _process.ExitCode; + } + + /// Memoised termination: SIGTERM, then after a grace period the whole process tree. Never throws. + public Task TerminateAsync() + { + lock (_lock) + { + TerminationRequested = true; + return _termination ??= TerminateCoreAsync(); + } + } + + private async Task TerminateCoreAsync() + { + try + { + if (HasExited) return; + if (!OperatingSystem.IsWindows() && TrySendSigterm(Pid)) + { + if (await WaitForExitAsync(GracePeriod).ConfigureAwait(false)) return; + } + KillTree(); + await WaitForExitAsync(KillWait).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or ObjectDisposedException) + { + // The child is gone or the handle was released; either way there is nothing left to terminate. + } + } + + private async Task WaitForExitAsync(TimeSpan timeout) + { + if (HasExited) return true; + using var cts = new CancellationTokenSource(timeout); + try + { + await _process.WaitForExitAsync(cts.Token).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + return HasExited; + } + } + + private void KillTree() + { + try + { + if (!HasExited) _process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException or AggregateException) + { + // Already exited, or a descendant could not be signalled; the wait that follows reports what happened. + } + } + + private void OnTimeout(string message) + { + lock (_lock) + { + if (_disposed || TerminationRequested || HasExited) return; + TimeoutError ??= new ClaudeCodeException(ClaudeCodeException.Codes.Timeout, message); + } + _ = TerminateAsync(); + } + + private async Task PumpStderrAsync() + { + try + { + var reader = _process.StandardError; + var buffer = new char[4096]; + int read; + while ((read = await reader.ReadAsync(buffer).ConfigureAwait(false)) > 0) + { + lock (_stderr) + { + _stderr.Append(buffer, 0, read); + if (_stderr.Length > MaxStderrChars) _stderr.Remove(0, _stderr.Length - MaxStderrChars); + } + } + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException) + { + // stderr closed underneath us; the tail collected so far stands. + } + } + + [DllImport("libc", EntryPoint = "kill", SetLastError = true)] + private static extern int SysKill(int pid, int signal); + + /// SIGTERM through libc, else through the kill command. Returns false when neither could signal the child. + private static bool TrySendSigterm(int pid) + { + try + { + return SysKill(pid, SigTerm) == 0; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException) + { + // Fall through to the command. + } + try + { + var psi = new ProcessStartInfo("kill") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.ArgumentList.Add("-TERM"); + psi.ArgumentList.Add(pid.ToString(CultureInfo.InvariantCulture)); + using var kill = Process.Start(psi); + return kill is not null && kill.WaitForExit(2000) && kill.ExitCode == 0; + } + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException) + { + return false; + } + } + + /// A positive millisecond count from the environment, or the fallback when unset. Invalid values fail loudly. + public static TimeSpan TimeoutSetting(Func getEnv, string name, TimeSpan fallback) + { + ArgumentNullException.ThrowIfNull(getEnv); + var raw = getEnv(name)?.Trim(); + if (string.IsNullOrEmpty(raw)) return fallback; + if (!int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var milliseconds) || milliseconds <= 0) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Timeout, $"{name} must be a positive integer number of milliseconds"); + return TimeSpan.FromMilliseconds(milliseconds); + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + } + try { _idle.Cancel(); } catch (ObjectDisposedException) { } + try { _total.Cancel(); } catch (ObjectDisposedException) { } + KillTree(); + _idle.Dispose(); + _total.Dispose(); + _process.Dispose(); + } + + [GeneratedRegex(@"[\u0000-\u001f\u007f]+")] + private static partial Regex ControlCharacters(); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs new file mode 100644 index 0000000..fc43b10 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs @@ -0,0 +1,231 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Providers; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// The transcript as NDJSON records (already @-escaped) plus the image files it references. +public sealed record SerializedTranscript( + IReadOnlyList Records, + IReadOnlyList AttachmentPaths, + int ImageCount, + long ImageBytes, + long TranscriptBytes); + +/// +/// Serialises the conversation into append-stable JSON records, one per message after a header record, each carried +/// as its own text block of a single stream-json user message. Historical tool identities are tied to the current +/// catalogue; UI-only details stay out of the model transcript. +/// +public static class ContextSerializer +{ + public const string Protocol = "volcanic-claude-code-context-v1"; + public const string Instruction = + "Continue this conversation. Treat each following JSON record as conversation data, preserve role boundaries, " + + "and answer only the current request. Use available MCP tools when a tool is needed. " + + "Bracketed unavailable tool labels are historical data, not callable tools."; + public const int MaxTranscriptBytes = 32 * 1024 * 1024; + public const string AttachmentInstructionPrefix = "Generated image attachments for image_attachment blocks: "; + + private static readonly JsonSerializerOptions RecordOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + /// + /// The one stream-json user message written to Claude Code's stdin. The last transcript block carries a 1h + /// cache breakpoint when is set; attachment references follow it, outside the breakpoint. + /// + public static JsonObject BuildStdinMessage(LlmContext context, ToolCatalog catalog, ClaudeImageStore images, bool cacheBreakpoint) + { + var transcript = Serialize(context, catalog, images); + var content = new JsonArray(); + for (var i = 0; i < transcript.Records.Count; i++) + { + var block = new JsonObject { ["type"] = "text", ["text"] = transcript.Records[i] }; + if (cacheBreakpoint && i == transcript.Records.Count - 1) + block["cache_control"] = new JsonObject { ["type"] = "ephemeral", ["ttl"] = "1h" }; + content.Add(block); + } + if (transcript.AttachmentPaths.Count > 0) + { + // Quoted absolute references: Claude runs in the session directory, where a relative reference would + // resolve against the project, and the quotes keep a temporary root containing spaces in one reference. + var references = string.Join(" ", transcript.AttachmentPaths.Select(path => $"@\"{path}\"")); + content.Add(new JsonObject { ["type"] = "text", ["text"] = $"{AttachmentInstructionPrefix}{references}." }); + } + return new JsonObject + { + ["type"] = "user", + ["message"] = new JsonObject { ["role"] = "user", ["content"] = content }, + }; + } + + /// Serialises the header and one record per message. Images are written to . + public static SerializedTranscript Serialize(LlmContext context, ToolCatalog catalog, ClaudeImageStore images) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(images); + + var attachments = new List(); + var written = new HashSet(StringComparer.Ordinal); + var imageCount = 0; + long imageBytes = 0; + var historicalNamesByCallId = new Dictionary(StringComparer.Ordinal); + + string HistoricalName(string toolName) => + catalog.ToolToTransport.TryGetValue(toolName, out var transport) ? transport : UnavailableToolLabel(toolName); + + JsonObject Attach(ImageContent image) + { + imageCount++; + if (imageCount > ClaudeImageStore.MaxImagesPerRequest) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"At most {ClaudeImageStore.MaxImagesPerRequest} images are supported per request"); + var attachment = images.Add(image); + if (written.Add(attachment.FileName)) + { + imageBytes += attachment.Bytes; + if (imageBytes > ClaudeImageStore.MaxTotalImageBytes) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Aggregate image size exceeds {ClaudeImageStore.MaxTotalImageBytes} bytes"); + if (attachment.AbsolutePath.Contains('"')) + throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Images cannot be attached from a path containing a double quote: {attachment.AbsolutePath}"); + attachments.Add(attachment.AbsolutePath); + } + return new JsonObject { ["type"] = "image_attachment", ["attachment"] = attachment.FileName, ["mimeType"] = image.MimeType }; + } + + JsonArray UserContent(IReadOnlyList blocks, string role) + { + var array = new JsonArray(); + foreach (var block in blocks) + { + array.Add(block switch + { + TextContent text => new JsonObject { ["type"] = "text", ["text"] = text.Text }, + ImageContent image => Attach(image), + _ => throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"{BlockName(block)} content must not appear in {role} messages"), + }); + } + return array; + } + + var records = new List + { + new() + { + ["protocol"] = Protocol, + ["instruction"] = Instruction, + ["toolNameMap"] = new JsonArray(catalog.Tools + .Select(tool => (JsonNode)new JsonObject { ["transportName"] = catalog.ToolToTransport[tool.Name], ["toolName"] = tool.Name }) + .ToArray()), + }, + }; + + foreach (var message in context.Messages) + { + switch (message) + { + case UserMessage user: + records.Add(new JsonObject { ["role"] = "user", ["content"] = UserRecordContent(user.Content, UserContent) }); + break; + case CustomMessage custom: + // Custom messages reach the model as user turns, exactly as the core's transcript normaliser renders them. + records.Add(new JsonObject { ["role"] = "user", ["content"] = UserRecordContent(custom.Content, UserContent) }); + break; + case AssistantMessage assistant: + { + var content = new JsonArray(); + foreach (var block in assistant.Content) + { + switch (block) + { + case TextContent text: + content.Add(new JsonObject { ["type"] = "text", ["text"] = text.Text }); + break; + case ThinkingContent thinking: + { + var node = new JsonObject { ["type"] = "thinking", ["thinking"] = thinking.Thinking }; + if (thinking.Redacted) node["redacted"] = true; + content.Add(node); + break; + } + case ToolCallContent call: + { + if (string.IsNullOrWhiteSpace(call.Id)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-call ID must be a nonempty string"); + if (string.IsNullOrWhiteSpace(call.Name)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-call name must be a nonempty string"); + var name = HistoricalName(call.Name); + historicalNamesByCallId[call.Id] = name; + var arguments = call.Arguments.ValueKind == JsonValueKind.Object + ? JsonNode.Parse(call.Arguments.GetRawText()) ?? new JsonObject() + : new JsonObject(); + content.Add(new JsonObject { ["type"] = "toolCall", ["id"] = call.Id, ["name"] = name, ["arguments"] = arguments }); + break; + } + default: + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"{BlockName(block)} content must not appear in assistant messages"); + } + } + if (content.Count > 0) records.Add(new JsonObject { ["role"] = "assistant", ["content"] = content }); + break; + } + case ToolResultMessage result: + { + if (string.IsNullOrWhiteSpace(result.ToolCallId)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-result ID must be a nonempty string"); + if (string.IsNullOrWhiteSpace(result.ToolName)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-result name must be a nonempty string"); + var toolName = historicalNamesByCallId.TryGetValue(result.ToolCallId, out var paired) ? paired : HistoricalName(result.ToolName); + records.Add(new JsonObject + { + ["role"] = "toolResult", + ["toolCallId"] = result.ToolCallId, + ["toolName"] = toolName, + ["content"] = UserContent(result.Content, "toolResult"), + ["isError"] = result.IsError, + }); + break; + } + default: + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Unsupported message role: {message.Role}"); + } + } + + var lines = new List(records.Count); + long transcriptBytes = 0; + foreach (var record in records) + { + var line = EscapeAtSigns(record.ToJsonString(RecordOptions)); + transcriptBytes += Encoding.UTF8.GetByteCount(line) + (lines.Count > 0 ? 1 : 0); + lines.Add(line); + } + if (transcriptBytes > MaxTranscriptBytes) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContextOverflow, + $"context_length_exceeded: Serialized context exceeds {MaxTranscriptBytes} bytes"); + + return new SerializedTranscript(lines, attachments, imageCount, imageBytes, transcriptBytes); + } + + /// + /// Claude Code expands @path references anywhere in a prompt, so every @ in a record is written as + /// the JSON escape @: the record still parses to the same text, and nothing is expanded. + /// + public static string EscapeAtSigns(string json) => json.Replace("@", "\\u0040", StringComparison.Ordinal); + + /// Label for a tool that is no longer in the catalogue; bracketed so the model reads it as history. + public static string UnavailableToolLabel(string toolName) => $"[unavailable tool {toolName}]"; + + private static JsonNode UserRecordContent(IReadOnlyList blocks, Func, string, JsonArray> serialize) => + blocks.Count == 1 && blocks[0] is TextContent single ? JsonValue.Create(single.Text) : serialize(blocks, "user"); + + private static string BlockName(ContentBlock block) => block switch + { + TextContent => "text", + ImageContent => "image", + ThinkingContent => "thinking", + ToolCallContent => "toolCall", + _ => block.GetType().Name, + }; +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/DoctorCommand.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/DoctorCommand.cs new file mode 100644 index 0000000..25acc5e --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/DoctorCommand.cs @@ -0,0 +1,190 @@ +using System.Text.Json; +using Volcanic.AgenticCore.Abstractions.Extensions; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Ui; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// Outcome of a real initialize + tools/list handshake against the proposal bridge. +public sealed record BridgeProbeResult(bool Ok, IReadOnlyList Argv, string Detail); + +/// /claude-code-doctor: reports the CLI path, version, login, required flags and a bridge handshake probe. +public static class DoctorCommand +{ + public const string Name = "claude-code-doctor"; + public const string Description = "Check the Claude Code CLI, its login, the required flags and the proposal MCP bridge"; + public static readonly TimeSpan BridgeProbeTimeout = TimeSpan.FromSeconds(10); + + public static CommandDefinition Create(IExtensionApi api, ClaudeCodeApiOptions options, SessionRegistry registry) + { + ArgumentNullException.ThrowIfNull(api); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(registry); + return new CommandDefinition(Description, async (_, ctx, ct) => + { + string report; + var ok = false; + try + { + (report, ok) = await BuildReportAsync(options, registry, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + report = $"# Claude Code doctor\n\nThe doctor itself failed: {ex.Message}"; + } + // Idle and triggerTurn false: the report is appended to the transcript for the user without starting a turn. + api.SendMessage(CustomMessage.FromText(Name, report), MessageDelivery.Steer, triggerTurn: false); + if (ctx.HasUi) + { + ctx.Ui.Notify( + ok ? "Claude Code doctor: everything checks out" : $"Claude Code doctor: problems found; see the {Name} message", + ok ? NotifyKind.Info : NotifyKind.Warning); + } + }); + } + + /// Builds the markdown report. Ok is false when anything would stop a request from working. + public static async Task<(string Report, bool Ok)> BuildReportAsync(ClaudeCodeApiOptions options, SessionRegistry registry, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(registry); + var lines = new List { "# Claude Code doctor", "" }; + var ok = true; + + var claudePath = options.ClaudePath; + ClaudeInstallationInfo? info = null; + string? inspectError = null; + if (string.IsNullOrEmpty(claudePath)) + { + ok = false; + lines.Add($"- Claude Code: **not found**. Install the `claude` CLI or set `{ClaudeInstallation.PathVariable}` to its executable."); + } + else + { + try + { + info = await ClaudeInstallation.InspectAsync(claudePath, cancellationToken).ConfigureAwait(false); + } + catch (ClaudeCodeException ex) + { + inspectError = ex.Message; + } + } + if (info is not null) + { + lines.Add($"- Claude Code: `{info.Path}` version {info.Version ?? "unknown"}"); + if (info.LoggedIn) + { + var subscription = info.SubscriptionType is null ? "" : $" ({info.SubscriptionType} subscription)"; + lines.Add($"- Auth: logged in via {info.AuthMethod ?? "unknown"}{subscription}"); + } + else + { + ok = false; + lines.Add("- Auth: **not logged in**; run `claude auth login` with a Claude subscription"); + } + if (info.MissingFlags.Count == 0) lines.Add("- Required flags: all present"); + else + { + ok = false; + lines.Add($"- Required flags: **missing** {string.Join(", ", info.MissingFlags)}; update Claude Code"); + } + foreach (var warning in info.Warnings) lines.Add($"- Warning: {warning}"); + } + else if (inspectError is not null) + { + ok = false; + lines.Add($"- Claude Code: `{claudePath}` could not be inspected: {inspectError}"); + } + + var probe = await ProbeBridgeAsync(options.DotnetPath, options.BridgeDllPath, BridgeProbeTimeout, cancellationToken).ConfigureAwait(false); + if (!probe.Ok) ok = false; + lines.Add($"- Bridge: {(probe.Ok ? "ok" : "**BROKEN**")} via `{JsonSerializer.Serialize(probe.Argv)}` ({probe.Detail})"); + + if (ClaudeArgs.FindDeniedVariable(options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment()) is { } denied) + { + ok = false; + lines.Add($"- Environment: **{denied} is set**; the provider refuses to run while API-key or third-party credentials are present"); + } + + lines.Add($"- Models: {string.Join(", ", ClaudeCodeModels.All.Select(m => m.Id))}"); + lines.Add($"- Registered sessions: {registry.Count}"); + lines.Add(""); + lines.Add(ok ? "Everything checks out." : $"Problems found; fix the items in bold and run `/{Name}` again."); + return (string.Join("\n", lines), ok); + } + + /// + /// Completes a real initialize plus tools/list handshake against the bridge with a one-tool catalogue, mirroring + /// what Claude Code hands it (the allow-listed environment plus the catalogue and ready-marker variables). + /// + public static async Task ProbeBridgeAsync(string? dotnetPath, string? bridgeDllPath, TimeSpan timeout, CancellationToken cancellationToken) + { + var argv = new List { dotnetPath ?? "dotnet", bridgeDllPath ?? BridgeLocator.BridgeFileName }; + if (string.IsNullOrEmpty(dotnetPath)) return new BridgeProbeResult(false, argv, "the dotnet muxer was not located"); + if (string.IsNullOrEmpty(bridgeDllPath) || !File.Exists(bridgeDllPath)) + return new BridgeProbeResult(false, argv, $"bridge assembly not found{(bridgeDllPath is null ? "" : $": {bridgeDllPath}")}"); + + var directory = Directory.CreateTempSubdirectory("volcanic-claude-doctor-").FullName; + try + { + var catalogPath = Path.Combine(directory, "catalog.json"); + var readyPath = Path.Combine(directory, "ready"); + await File.WriteAllTextAsync(catalogPath, "[{\"name\":\"probe\",\"description\":\"doctor probe\",\"inputSchema\":{\"type\":\"object\"}}]", cancellationToken).ConfigureAwait(false); + var env = new Dictionary(ClaudeArgs.AllowListed(ClaudeArgs.CurrentEnvironment(), maxOutputTokens: null), StringComparer.Ordinal) + { + [ClaudeArgs.CatalogVariable] = catalogPath, + [ClaudeArgs.ReadyVariable] = readyPath, + }; + using var process = ClaudeProcess.Start(new ClaudeProcessOptions(dotnetPath, [bridgeDllPath], directory, env, timeout, timeout)); + await process.WriteStdinAsync( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}", + cancellationToken).ConfigureAwait(false); + + var responses = new List(); + try + { + await foreach (var record in JsonlReader.ReadAsync(process.Stdout, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + process.Touch(); + responses.Add(record); + } + } + catch (ClaudeCodeException ex) + { + return new BridgeProbeResult(false, argv, $"bridge output was not JSONL: {ex.Message}"); + } + var exitCode = await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + if (process.TimeoutError is { } timeoutError) return new BridgeProbeResult(false, argv, timeoutError.Message); + if (exitCode != 0) return new BridgeProbeResult(false, argv, $"bridge process failed (exit code {exitCode}){Stderr(process)}"); + + var toolCount = -1; + foreach (var response in responses) + { + if (!response.TryGetProperty("id", out var id) || id.ValueKind != JsonValueKind.Number || !id.TryGetInt32(out var value) || value != 2) continue; + toolCount = response.TryGetProperty("result", out var result) && result.TryGetProperty("tools", out var tools) && tools.ValueKind == JsonValueKind.Array + ? tools.GetArrayLength() + : -1; + } + var ready = File.Exists(readyPath); + if (toolCount != 1 || !ready) + return new BridgeProbeResult(false, argv, + $"handshake failed ({(toolCount < 0 ? "no tools/list result" : $"{toolCount} tools")}, ready marker {(ready ? "written" : "missing")}){Stderr(process)}"); + return new BridgeProbeResult(true, argv, "handshake completed: 1 tool listed, ready marker written"); + } + catch (ClaudeCodeException ex) + { + return new BridgeProbeResult(false, argv, ex.Message); + } + finally + { + try { Directory.Delete(directory, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } + } + + private static string Stderr(ClaudeProcess process) + { + var excerpt = process.StderrExcerpt(256); + return excerpt.Length == 0 ? "" : $": {excerpt}"; + } +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/JsonlReader.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/JsonlReader.cs new file mode 100644 index 0000000..04f7492 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/JsonlReader.cs @@ -0,0 +1,79 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// Strict JSONL framing over a byte stream: records split on LF only, one optional CR before it, UTF-8, bounded size. +public static class JsonlReader +{ + public const int DefaultMaxRecordBytes = 8 * 1024 * 1024; + + public static async IAsyncEnumerable ReadAsync( + Stream stream, + int maxRecordBytes = DefaultMaxRecordBytes, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + var buffer = new byte[64 * 1024]; + using var pending = new MemoryStream(); + while (true) + { + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (read == 0) break; + var start = 0; + for (var i = 0; i < read; i++) + { + if (buffer[i] != (byte)'\n') continue; + pending.Write(buffer, start, i - start); + start = i + 1; + if (TryParse(pending, maxRecordBytes, out var element)) yield return element; + } + pending.Write(buffer, start, read - start); + if (pending.Length > maxRecordBytes + 1) throw Oversized(); + } + if (pending.Length > 0 && TryParse(pending, maxRecordBytes, out var last)) yield return last; + } + + /// Parses one framed line. Returns false for a blank line. The pending buffer is reset either way. + private static bool TryParse(MemoryStream pending, int maxRecordBytes, out JsonElement element) + { + var length = (int)pending.Length; + var bytes = pending.GetBuffer().AsMemory(0, length); + pending.SetLength(0); + if (length > 0 && bytes.Span[length - 1] == (byte)'\r') bytes = bytes[..(length - 1)]; + if (bytes.Length > maxRecordBytes) throw Oversized(); + if (IsBlank(bytes.Span)) + { + element = default; + return false; + } + element = Parse(bytes); + return true; + } + + /// Parses one record (no framing). Throws protocol_stream for malformed JSON. + public static JsonElement Parse(ReadOnlyMemory line) + { + try + { + using var document = JsonDocument.Parse(line); + return document.RootElement.Clone(); + } + catch (JsonException) + { + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolStream, "Claude Code emitted malformed JSONL"); + } + } + + private static bool IsBlank(ReadOnlySpan bytes) + { + foreach (var b in bytes) + { + if (b is not ((byte)' ' or (byte)'\t' or (byte)'\r')) return false; + } + return true; + } + + private static ClaudeCodeException Oversized() => + new(ClaudeCodeException.Codes.ProtocolStream, "Claude Code emitted an oversized JSONL record"); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/SessionRegistry.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/SessionRegistry.cs new file mode 100644 index 0000000..c2d0bb3 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/SessionRegistry.cs @@ -0,0 +1,155 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Abstractions.Ui; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// The per-session state a request needs: where Claude runs, where its images live, and how to notify the user. +public sealed record ClaudeSessionEntry(string Cwd, ClaudeImageStore Images, Action? Notify); + +/// +/// Process-global map of live sessions. A host can hold several sessions and every extension instance registers its +/// own, so a request must resolve the directory of the session it belongs to rather than the last one registered. +/// +public sealed partial class SessionRegistry +{ + public const string SourceSession = "session"; + public const string SourcePrompt = "prompt"; + public const string SourceOneshot = "oneshot"; + + private sealed record Registered(ClaudeSessionEntry Entry, long Sequence); + + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + private readonly Lock _lock = new(); + private long _sequence; + private ClaudeImageStore? _detached; + + /// The registry shared by every extension instance in the process. + public static SessionRegistry Shared { get; } = new(); + + public int Count => _sessions.Count; + + /// Registers or updates a session. A re-registration keeps the session's image store. + public ClaudeSessionEntry Register(string sessionId, string cwd, Action? notify = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(cwd); + var sequence = Interlocked.Increment(ref _sequence); + var registered = _sessions.AddOrUpdate( + sessionId, + _ => new Registered(new ClaudeSessionEntry(cwd, new ClaudeImageStore(), notify), sequence), + (_, existing) => new Registered(existing.Entry with { Cwd = cwd, Notify = notify }, sequence)); + return registered.Entry; + } + + /// Removes a session and deletes its image store. + public bool Remove(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (!_sessions.TryRemove(sessionId, out var registered)) return false; + registered.Entry.Images.Dispose(); + return true; + } + + public ClaudeSessionEntry? Get(string sessionId) => + sessionId is not null && _sessions.TryGetValue(sessionId, out var registered) ? registered.Entry : null; + + /// + /// Resolves the working directory for a request: the registered session, else the directory the system prompt + /// states, else (for tool-free one-shots such as compaction summaries) the newest live session. Tool-bearing + /// requests with no recognised directory are refused rather than run in another session's tree. + /// + public (string Cwd, ClaudeImageStore Images, string Source) Resolve(string? sessionId, LlmContext context, bool hasTools) + { + ArgumentNullException.ThrowIfNull(context); + var stated = PromptWorkingDirectory(context.SystemPrompt); + + if (sessionId is not null && _sessions.TryGetValue(sessionId, out var registered)) + { + var entry = registered.Entry; + if (stated is not null && !SameDirectory(stated, entry.Cwd)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory, + $"The system prompt names {stated} as the working directory but session {sessionId} runs in {entry.Cwd}; refusing to run Claude Code in another session's directory"); + return (RequireDirectory(entry.Cwd), entry.Images, SourceSession); + } + + var live = _sessions.Values.OrderBy(r => r.Sequence).Select(r => r.Entry).ToList(); + if (stated is not null) + { + // A session this process never registered (an extension driving its own loop, a host with several sessions): + // run where the prompt says and borrow private state from a live session, preferring one in that directory. + var host = live.LastOrDefault(e => SameDirectory(e.Cwd, stated)) ?? live.LastOrDefault(); + return (RequireDirectory(stated), host?.Images ?? DetachedImages(), SourcePrompt); + } + + if (!hasTools && live.Count > 0) + { + // Compaction summaries arrive with no session id, no tools and no directory: host them in the newest session. + var newest = live[^1]; + return (RequireDirectory(newest.Cwd), newest.Images, SourceOneshot); + } + + throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory, live.Count == 0 + ? "No session is registered and the system prompt names no working directory; the provider can only run inside a started session" + : $"This tool-bearing request ({sessionId ?? "no session id"}) has no registered session or recognised working directory in its system prompt and {live.Count} session(s) are live; refusing to borrow another session's directory"); + } + + /// + /// The working directory a system prompt states: the last <cwd> section or the last + /// Working directory: X line, whichever comes later. Sections nested in project context (repository-authored + /// instruction files) are discarded so a repository can never choose where Claude runs. + /// + public static string? PromptWorkingDirectory(string? systemPrompt) + { + if (string.IsNullOrEmpty(systemPrompt)) return null; + var authored = ProjectContextSpan().Matches(systemPrompt).Select(m => (Start: m.Index, End: m.Index + m.Length)).ToList(); + bool RepositoryAuthored(int index) => authored.Any(span => index >= span.Start && index < span.End); + + Match? best = null; + foreach (Match section in CwdSection().Matches(systemPrompt)) + { + if (!RepositoryAuthored(section.Index)) best = section; + } + foreach (Match line in WorkingDirectoryLine().Matches(systemPrompt)) + { + if (!RepositoryAuthored(line.Index) && (best is null || line.Index > best.Index)) best = line; + } + var stated = best?.Groups[1].Value.Trim(); + return string.IsNullOrEmpty(stated) ? null : stated; + } + + /// Separator-insensitive comparison; case-insensitive on Windows. + public static bool SameDirectory(string left, string right) => + string.Equals(Normalize(left), Normalize(right), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private static string Normalize(string path) + { + var separated = path.Replace('\\', '/'); + var trimmed = separated.TrimEnd('/'); + return trimmed.Length == 0 ? separated : trimmed; + } + + private static string RequireDirectory(string cwd) + { + if (!Path.IsPathRooted(cwd)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory, $"The session working directory is not absolute: {cwd}"); + if (!Directory.Exists(cwd)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.WorkingDirectory, $"The session working directory is unavailable: {cwd}; restart the session in an existing directory"); + return cwd; + } + + private ClaudeImageStore DetachedImages() + { + lock (_lock) return _detached ??= new ClaudeImageStore(); + } + + [GeneratedRegex(@"<(project_context|project_instructions)\b[^>]*>[\s\S]*?", RegexOptions.CultureInvariant)] + private static partial Regex ProjectContextSpan(); + + [GeneratedRegex(@"\r?\n([^\n]+)\r?\n", RegexOptions.CultureInvariant)] + private static partial Regex CwdSection(); + + [GeneratedRegex(@"^(?:Current )?[Ww]orking directory: ([^\r\n]+?)[ \t]*\r?$", RegexOptions.Multiline | RegexOptions.CultureInvariant)] + private static partial Regex WorkingDirectoryLine(); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs new file mode 100644 index 0000000..2c30320 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs @@ -0,0 +1,750 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Providers; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// +/// Maps Claude Code's stream-json stdout records to s through one +/// . Validates the system/init record, latches a handoff on a +/// tool_use or length stop (after which every later stream event belongs to a turn the core never asked for +/// and is ignored), and reports Claude Code's mid-response recoveries as a retryable stream_interrupted failure. +/// +public sealed partial class StreamEventMapper +{ + // api_retry categories that describe a transient transport or server failure. Every other category + // (billing_error, authentication_failed, invalid_request, ...) cannot be cleared by repeating the turn. + private static readonly HashSet RetryableInterruptions = new(StringComparer.Ordinal) { "overloaded", "server_error", "unknown" }; + private const string InterruptedPhrase = "Claude Code API stream ended before message_stop"; + private const long EpochMillisecondsThreshold = 100_000_000_000; + + private enum BlockKind { None, Text, Thinking, ToolCall } + + private readonly ToolCatalog _catalog; + private readonly List _notices = []; + private readonly StringBuilder _partialJson = new(); + private bool _messageStarted; + private bool _messageStopped; + private bool _successfulResult; + private bool _finished; + private string? _streamMessageId; + private string? _stopReason; + private string? _deferredToolArguments; + private string? _rateLimitFailure; + private string? _assistantDiagnostic; + private string? _openToolName; + private int? _openIndex; + private BlockKind _openKind; + private JsonElement? _initialInput; + private int _input, _output, _cacheRead, _cacheWrite, _reasoning; + + public StreamEventMapper(Model model, ToolCatalog catalog, AssistantMessageBuilder? builder = null) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(catalog); + Model = model; + _catalog = catalog; + Builder = builder ?? new AssistantMessageBuilder(model); + } + + public Model Model { get; } + + /// The builder behind every event; the provider uses it to Fail the stream. + public AssistantMessageBuilder Builder { get; } + + public bool SawInit { get; private set; } + public bool SawResult { get; private set; } + + /// A tool-use or length stop was reported: the provider terminates Claude and later stream events are ignored. + public bool HandoffLatched { get; private set; } + + /// The stop reason a completed response will carry: ToolUse or Length after a latch, Stop after a successful result. + public StopReason? PendingStop { get; private set; } + + public string? ResponseModel { get; private set; } + public Usage Usage => Builder.Usage; + public bool MessageStarted => _messageStarted; + public string? RawStopReason => _stopReason; + public bool HasSuccessfulResult => _successfulResult; + public bool IsComplete => _finished; + + /// A held tool-argument failure for the provider to report when no recovery signal claimed it. + public string? DeferredError => _deferredToolArguments; + + /// The most recent rejected rate limit, used as the failure detail when Claude exits without a terminal event. + public string? RateLimitFailure => _rateLimitFailure; + + /// Rate-limit notices (warnings and rejections) in arrival order, formatted for a user notification. + public IReadOnlyList Notices => _notices; + + public int? ServedContextWindow { get; private set; } + public int? ServedMaxOutputTokens { get; private set; } + + /// Maps one stdout record to the events to publish. Throws on protocol failures. + public IReadOnlyList Accept(JsonElement record) + { + if (record.ValueKind != JsonValueKind.Object) throw Stream("Invalid Claude event record"); + var type = Str(record, "type"); + var subtype = Str(record, "subtype"); + if (type == "system" && subtype == "init") return ValidateInit(record); + if (!SawInit) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"Claude emitted a {RecordKind(type, subtype)} record before initialization"); + if (SawResult) throw Stream("Claude emitted a record after its result"); + + switch (type) + { + case "stream_event": + // Once a handoff is latched the provider is terminating Claude, but records already in the pipe still + // arrive. Claude Code answers a tool denial or an output limit with another message of its own, so these + // events belong to a turn the core never asked for: neither published nor rejected as protocol drift. + if (HandoffLatched) return []; + return AcceptStreamEvent(Obj(record, "event") ?? throw Stream("Claude stream_event did not contain an event")); + case "result": + return AcceptResult(record); + case "rate_limit_event": + AcceptRateLimit(record); + return []; + case "assistant": + RejectInterruptedStream(record, type, subtype); + AcceptAssistant(record); + return []; + case "user": + case "system": + // Completed user echoes and non-init system status records are redundant with the partial stream events. + RejectInterruptedStream(record, type, subtype); + return []; + default: + throw Stream($"Unsupported Claude record type: {type ?? "untyped"}"); + } + } + + /// + /// The terminal Done event, produced once the provider has validated the process exit and private state. + /// Reason: ToolUse or Length after a latched handoff, else Stop/Length from the successful result. + /// + public AssistantMessageEvent Complete() + { + if (_finished) throw new InvalidOperationException("The response was already completed."); + ThrowDeferredFailure(); + if (_openIndex is not null) throw Stream("Claude response completed with unclosed content blocks"); + switch (PendingStop) + { + case StopReason.ToolUse: + if (!Builder.Current.Content.OfType().Any()) throw Stream("Claude reported tool use without a tool call"); + break; + case StopReason.Length: + if (!IsLengthStop(_stopReason)) throw Stream("Claude did not report an output limit stop"); + break; + default: + if (!_successfulResult) throw Stream("Claude result was not ready for completion"); + break; + } + _finished = true; + return Builder.Done(PendingStop ?? StopReason.Stop); + } + + /// Whether an error message is the API's context overflow, in any of Claude Code's wordings. + public static bool IsOverflow(string? message) => message is not null && Overflow().IsMatch(message); + + /// Prefixes an overflow message with context_length_exceeded: so the core compacts and retries. + public static string NormalizeOverflowMessage(string message) => + message.StartsWith("context_length_exceeded:", StringComparison.Ordinal) || !IsOverflow(message) + ? message + : $"context_length_exceeded: {message}"; + + // ---- init ---- + + private IReadOnlyList ValidateInit(JsonElement record) + { + if (SawInit) throw Init("Claude emitted duplicate initialization"); + + if (!record.TryGetProperty("tools", out var toolsElement) || toolsElement.ValueKind != JsonValueKind.Array) + throw Init("Claude initialization omitted tools"); + var observed = new List(); + foreach (var tool in toolsElement.EnumerateArray()) + { + if (tool.ValueKind != JsonValueKind.String) throw Init("Claude initialization contained an invalid tool name"); + observed.Add(tool.GetString()!); + } + var observedSet = observed.ToHashSet(StringComparer.Ordinal); + var expected = _catalog.TransportNames.ToHashSet(StringComparer.Ordinal); + if (observedSet.Count != observed.Count || !observedSet.SetEquals(expected)) + throw Init($"Claude Code initialized with an unexpected tool set (expected: {FormatNames(expected)}; observed: {FormatNames(observedSet)})"); + + if (Str(record, "permissionMode") != "dontAsk") throw Init("Claude Code did not enter dontAsk permission mode"); + + if (!record.TryGetProperty("slash_commands", out var slashCommands) || slashCommands.ValueKind != JsonValueKind.Array) + throw Init("Claude initialization omitted customization inventories"); + var loaded = new List(); + foreach (var field in new[] { "plugins", "skills", "slash_commands" }) + { + if (record.TryGetProperty(field, out var inventory) && inventory.ValueKind == JsonValueKind.Array && inventory.GetArrayLength() > 0) + loaded.Add($"{field}: {InventoryNames(inventory)}"); + } + if (loaded.Count > 0) throw Init($"Claude Code loaded unexpected customizations ({string.Join("; ", loaded)})"); + + var apiKeySource = Str(record, "apiKeySource"); + if (apiKeySource != "none") + throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth, $"Claude Code is not using subscription auth (apiKeySource: {apiKeySource ?? "missing"})"); + + if (record.TryGetProperty("mcp_server_errors", out var mcpErrors) && mcpErrors.ValueKind != JsonValueKind.Null) + { + if (mcpErrors.ValueKind != JsonValueKind.Array) throw Init("Claude initialization contained invalid MCP server errors"); + if (mcpErrors.GetArrayLength() > 0) + { + var details = mcpErrors.EnumerateArray().Select(e => $"{Str(e, "type") ?? "error"}: {Truncate(Str(e, "message") ?? "unknown", 512)}"); + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, $"Claude Code reported MCP initialization errors: {string.Join("; ", details)}"); + } + } + + if (!record.TryGetProperty("mcp_servers", out var servers) || servers.ValueKind != JsonValueKind.Array) + throw Init("Claude initialization omitted MCP inventory"); + if (_catalog.IsEmpty) + { + if (servers.GetArrayLength() > 0) + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, $"Claude Code loaded an unexpected MCP server ({InventoryNames(servers)})"); + } + else + { + var single = servers.GetArrayLength() == 1 ? servers[0] : (JsonElement?)null; + if (single is null || Str(single.Value, "name") != _catalog.ServerName || Str(single.Value, "status") != "connected") + throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, + $"The proposal MCP server did not initialize correctly (expected '{_catalog.ServerName}' connected; observed: {InventoryNames(servers)}{ServerStatuses(servers)})"); + } + + var model = Str(record, "model"); + if (string.IsNullOrEmpty(model)) throw Init("Claude initialization omitted the resolved model"); + ResponseModel = model; + Builder.SetResponseModel(model); + SawInit = true; + return [Builder.Start()]; + } + + // ---- stream events ---- + + private IReadOnlyList AcceptStreamEvent(JsonElement ev) + { + var type = Str(ev, "type"); + if (type == "message_start") + { + if (_messageStarted) throw Stream("Claude emitted duplicate message_start"); + var message = Obj(ev, "message") ?? throw Stream("message_start.message must be an object"); + if (Str(message, "model") is { Length: > 0 } model) + { + ResponseModel = model; + Builder.SetResponseModel(model); + } + _streamMessageId = Str(message, "id"); + if (_streamMessageId is not null) Builder.SetResponseId(_streamMessageId); + ApplyUsage(message); + _messageStarted = true; + return []; + } + if (_messageStopped) throw Stream($"{type ?? "untyped"} arrived after message_stop"); + if (type == "ping") return []; + if (!_messageStarted) throw Stream($"{type ?? "untyped"} arrived before message_start"); + + switch (type) + { + case "content_block_start": + return StartBlock(ev); + case "content_block_delta": + return DeltaBlock(ev); + case "content_block_stop": + return EndBlock(ev); + case "message_delta": + return AcceptMessageDelta(ev); + case "message_stop": + if (_openIndex is not null) throw Stream("Claude stopped with unclosed content blocks"); + _messageStopped = true; + Latch(); + return []; + default: + throw Stream($"Unsupported Claude stream event: {type ?? "untyped"}"); + } + } + + private IReadOnlyList AcceptMessageDelta(JsonElement ev) + { + var delta = Obj(ev, "delta") ?? throw Stream("message_delta.delta must be an object"); + if (delta.TryGetProperty("stop_reason", out var stop) && stop.ValueKind != JsonValueKind.Null) + { + // Claude exposes this as a string rather than a closed enum. Only tool_use and the length stops carry special + // meaning here, so future values are preserved as ordinary stops instead of rejecting a valid response. + if (stop.ValueKind != JsonValueKind.String || string.IsNullOrEmpty(stop.GetString())) + throw Stream($"Invalid Claude stop reason: {stop}"); + _stopReason = stop.GetString(); + } + ApplyUsage(ev); + Latch(); + return []; + } + + private void Latch() + { + if (_stopReason == "tool_use") + { + HandoffLatched = true; + PendingStop = StopReason.ToolUse; + } + else if (IsLengthStop(_stopReason)) + { + // The API accepts input plus max_tokens beyond the window and stops at the window with + // model_context_window_exceeded; Claude Code answers both stops with a synthetic continuation turn. + HandoffLatched = true; + PendingStop = StopReason.Length; + } + } + + private IReadOnlyList StartBlock(JsonElement ev) + { + var index = Index(ev); + if (_openIndex is not null) + throw Stream(_openIndex == index ? $"Duplicate content block index {index}" : $"Content block {index} started while block {_openIndex} is open"); + var source = Obj(ev, "content_block") ?? throw Stream("content_block_start.content_block must be an object"); + var events = new List(); + _partialJson.Clear(); + _initialInput = null; + _openToolName = null; + switch (Str(source, "type")) + { + case "text": + events.Add(Builder.StartText()); + if (Str(source, "text") is { Length: > 0 } text) events.Add(Builder.AppendText(text)); + _openKind = BlockKind.Text; + break; + case "thinking": + events.Add(Builder.StartThinking()); + if (Str(source, "thinking") is { Length: > 0 } thinking) events.Add(Builder.AppendThinking(thinking)); + if (Str(source, "signature") is { Length: > 0 } signature) Builder.AppendThinkingSignature(signature); + _openKind = BlockKind.Thinking; + break; + case "redacted_thinking": + { + var data = Str(source, "data"); + if (string.IsNullOrEmpty(data)) throw Stream("Claude emitted redacted thinking without opaque data"); + events.Add(Builder.StartThinking(redacted: true)); + Builder.AppendThinkingSignature(data); + _openKind = BlockKind.Thinking; + break; + } + case "tool_use": + { + var qualified = Str(source, "name") ?? ""; + if (!_catalog.TransportToTool.TryGetValue(qualified, out var toolName)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ToolUnknown, $"Claude proposed an unknown tool: {qualified}"); + var id = Str(source, "id"); + if (string.IsNullOrEmpty(id)) throw Stream("Claude emitted a tool without an ID"); + if (source.TryGetProperty("input", out var input) && input.ValueKind == JsonValueKind.Object && input.EnumerateObject().Any()) + _initialInput = input.Clone(); + events.Add(Builder.StartToolCall(id, toolName)); + _openToolName = toolName; + _openKind = BlockKind.ToolCall; + break; + } + default: + throw Stream($"Unsupported Claude content block: {Str(source, "type") ?? "untyped"}"); + } + _openIndex = index; + return events; + } + + private IReadOnlyList DeltaBlock(JsonElement ev) + { + var index = Index(ev); + if (_openIndex != index) throw Stream($"Delta for unknown content block {index}"); + var delta = Obj(ev, "delta") ?? throw Stream("content_block_delta.delta must be an object"); + var deltaType = Str(delta, "type"); + switch (deltaType) + { + case "text_delta" when _openKind == BlockKind.Text && Str(delta, "text") is { } text: + return [Builder.AppendText(text)]; + case "thinking_delta" when _openKind == BlockKind.Thinking && Str(delta, "thinking") is { } thinking: + return [Builder.AppendThinking(thinking)]; + case "signature_delta" when _openKind == BlockKind.Thinking && Str(delta, "signature") is { } signature: + Builder.AppendThinkingSignature(signature); + return []; + case "input_json_delta" when _openKind == BlockKind.ToolCall && Str(delta, "partial_json") is { } partial: + _partialJson.Append(partial); + return [Builder.AppendToolCallDelta(partial)]; + default: + throw Stream($"Delta {deltaType ?? "untyped"} did not match its content block"); + } + } + + private IReadOnlyList EndBlock(JsonElement ev) + { + var index = Index(ev); + if (_openIndex != index) throw Stream($"Stop for unknown content block {index}"); + var kind = _openKind; + _openIndex = null; + _openKind = BlockKind.None; + switch (kind) + { + case BlockKind.Text: + return [Builder.EndText()]; + case BlockKind.Thinking: + return [Builder.EndThinking()]; + case BlockKind.ToolCall: + { + var raw = _partialJson.ToString(); + _partialJson.Clear(); + if (raw.Length > 0) + { + JsonElement parsed; + try + { + using var document = JsonDocument.Parse(raw); + parsed = document.RootElement.Clone(); + } + catch (JsonException) + { + parsed = default; + } + if (parsed.ValueKind != JsonValueKind.Object) + { + // A connection drop inside streamed tool arguments truncates the JSON. Claude Code then closes the + // block, stops the message and announces its recovery, so hold this failure: if a recovery signal + // follows, the retryable interruption is the accurate explanation. The block is never published. + _deferredToolArguments = $"Claude emitted invalid arguments for tool {_openToolName}"; + Builder.EndToolCall(); + return []; + } + return [Builder.EndToolCall(parsed)]; + } + return [_initialInput is { } initial ? Builder.EndToolCall(initial) : Builder.EndToolCall()]; + } + default: + throw Stream($"Missing output block for source index {index}"); + } + } + + // ---- result ---- + + private IReadOnlyList AcceptResult(JsonElement record) + { + if (!record.TryGetProperty("is_error", out var isErrorElement) || isErrorElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw Stream("Claude result omitted a boolean is_error field"); + var isError = isErrorElement.GetBoolean(); + string? resultText = null; + if (record.TryGetProperty("result", out var resultElement) && resultElement.ValueKind != JsonValueKind.Null) + { + if (resultElement.ValueKind != JsonValueKind.String) throw Stream("Claude result contained a non-string result field"); + resultText = resultElement.GetString(); + } + SawResult = true; + ApplyUsage(record); + ApplyModelUsage(record); + + if (isError) + { + if (IsExpectedHandoffTermination(record)) return []; + ThrowDeferredFailure(); + var status = record.TryGetProperty("api_error_status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number + ? $" ({statusElement.GetRawText()})" + : ""; + var detail = FirstNonEmpty(resultText, _assistantDiagnostic, ErrorsText(record), Str(record, "terminal_reason"), _rateLimitFailure) ?? "unknown error"; + var message = $"Claude Code request failed{status}: {detail}"; + if (IsOverflow(message)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContextOverflow, $"context_length_exceeded: {message}"); + throw Stream(message); + } + + // After a latched handoff a success result belongs to Claude Code's own continuation turn; the proposal stands. + if (HandoffLatched) return []; + ThrowDeferredFailure(); + // Only a successful result must account for every block it opened; an error result is reported as itself. + if (_openIndex is not null) throw Stream("Claude result arrived with unclosed content blocks"); + if (_stopReason is null && Str(record, "stop_reason") is { Length: > 0 } resultStop) _stopReason = resultStop; + if (_stopReason == "tool_use") throw Stream("Claude returned success after a tool-use stop"); + + var events = new List(); + if (Builder.Current.Content.Count == 0 && !string.IsNullOrEmpty(resultText)) + { + events.Add(Builder.StartText()); + events.Add(Builder.AppendText(resultText)); + events.Add(Builder.EndText()); + } + _successfulResult = true; + PendingStop = IsLengthStop(_stopReason) ? StopReason.Length : StopReason.Stop; + return events; + } + + /// Whether an error result is only the provider's own handoff termination (SIGTERM after a tool-use or length stop). + private bool IsExpectedHandoffTermination(JsonElement record) + { + if (!HandoffLatched) return false; + if (Str(record, "subtype") != "error_during_execution") return false; + if (record.TryGetProperty("api_error_status", out var status) && status.ValueKind != JsonValueKind.Null) return false; + if (record.TryGetProperty("result", out var result) && result.ValueKind != JsonValueKind.Null) return false; + return true; + } + + private void ThrowDeferredFailure() + { + var message = _deferredToolArguments; + if (message is null) return; + _deferredToolArguments = null; + throw Stream(message); + } + + // ---- diagnostics ---- + + /// + /// Claude Code recovers from an API failure that arrives after this response began streaming by replaying the + /// request, continuing from the partial it kept, or re-requesting without streaming. The events already published + /// are append-only, so none of those recoveries can be published here: the provider reports one retryable failure + /// and the core re-runs the turn from the unchanged context in a fresh process (a prompt-cache hit). + /// + private void RejectInterruptedStream(JsonElement record, string? type, string? subtype) + { + // Before the response starts a retry is an ordinary pre-stream retry. After a tool-use or length stop the + // provider is already terminating Claude; records about Claude's own next request must not invalidate the proposal. + if (!_messageStarted || _stopReason == "tool_use" || IsLengthStop(_stopReason)) return; + string? cause = null; + if (type == "system" && subtype == "api_retry") + { + var category = Str(record, "error") is { Length: > 0 } error ? error : "unknown"; + var status = record.TryGetProperty("error_status", out var statusElement) && statusElement.ValueKind == JsonValueKind.Number + ? $", HTTP {statusElement.GetRawText()}" + : ""; + // A recorded rate-limit rejection already carries the reset time; it is more useful than the category alone. + if (category == "rate_limit" && _rateLimitFailure is not null) + throw new ClaudeCodeException(ClaudeCodeException.Codes.StreamInterrupted, _rateLimitFailure, retryable: true); + if (!RetryableInterruptions.Contains(category)) + throw new ClaudeCodeException(ClaudeCodeException.Codes.StreamInterrupted, $"Claude Code API request failed mid-response ({category}{status})"); + cause = $"Claude Code began retrying: {category}{status}"; + } + else if (type == "assistant") + { + if (Str(record, "error") is not null || Bool(record, "is_api_error_message") == true) + cause = "Claude Code reported a mid-response API error"; + else if (!_messageStopped && Obj(record, "message") is { } message && Str(message, "id") is { } id && id != _streamMessageId) + // A different message id before this stream stops is Claude Code's non-streaming replacement. + cause = "Claude Code replaced the stream with a non-streaming request"; + } + else if (type == "user" && Bool(record, "isSynthetic") == true) + { + // Claude Code's synthetic user turns ("Resume directly ...") each start another internal model turn. + cause = "Claude Code asked the model to continue a cut-off response"; + } + // The fixed "stream ended before message_stop" phrasing is what the core's retry classifier matches. + if (cause is not null) + throw new ClaudeCodeException(ClaudeCodeException.Codes.StreamInterrupted, $"{InterruptedPhrase} ({cause})", retryable: true); + } + + private void AcceptAssistant(JsonElement record) + { + if (_assistantDiagnostic is not null) return; + var error = Str(record, "error")?.Trim() ?? ""; + var text = ""; + if (Obj(record, "message") is { } message && message.TryGetProperty("content", out var content)) + { + if (content.ValueKind == JsonValueKind.String) text = content.GetString()!.Trim(); + else if (content.ValueKind == JsonValueKind.Array) + text = string.Join(" ", content.EnumerateArray() + .Where(block => block.ValueKind == JsonValueKind.Object && Str(block, "type") == "text") + .Select(block => Str(block, "text")?.Trim()) + .Where(t => !string.IsNullOrEmpty(t))); + } + var diagnostic = string.Join(": ", new[] { error, text }.Where(part => part.Length > 0)); + if (diagnostic.Length > 0) _assistantDiagnostic = diagnostic; + } + + private void AcceptRateLimit(JsonElement record) + { + // Rate-limit events are advisory; malformed details must not fail an otherwise valid request. + if (!record.TryGetProperty("rate_limit_info", out var info) || info.ValueKind != JsonValueKind.Object) return; + var notice = ParseRateLimitNotice(info); + if (notice is null) return; + _notices.Add(FormatNotice(notice)); + if (notice.Status == "rejected") _rateLimitFailure = RejectionMessage(notice); + } + + private sealed record RateLimitNotice( + string Status, + string RateLimitType, + double? Utilization, + DateTimeOffset? ResetsAt, + string? OverageStatus, + DateTimeOffset? OverageResetsAt, + string? OverageDisabledReason, + bool IsUsingOverage); + + private static RateLimitNotice? ParseRateLimitNotice(JsonElement info) + { + var primaryStatus = AlertStatus(Str(info, "status")); + var overageStatus = AlertStatus(Str(info, "overageStatus")); + var usingOverage = Bool(info, "isUsingOverage") == true; + // org_level_disabled is the normal repeated state without usage credits, so overage constrains a request only + // together with a plan rejection or actual overage use. + var overageBlocking = overageStatus == "rejected" && (primaryStatus == "rejected" || usingOverage); + if (primaryStatus is null && !overageBlocking) return null; + var status = primaryStatus == "rejected" || overageBlocking ? "rejected" : "allowed_warning"; + var hasPrimary = primaryStatus is not null; + var overageRelevant = overageStatus is not null && (primaryStatus == "rejected" || usingOverage); + var rateLimitType = hasPrimary + ? Str(info, "rateLimitType") is { } t && !string.IsNullOrWhiteSpace(t) ? t.Trim() : "unknown" + : "overage"; + var resetsAt = Timestamp(info, hasPrimary ? "resetsAt" : "overageResetsAt"); + var overageReset = Timestamp(info, "overageResetsAt"); + var utilization = info.TryGetProperty("utilization", out var u) && u.ValueKind == JsonValueKind.Number && u.TryGetDouble(out var value) && value is >= 0 and <= 1 + ? value + : (double?)null; + var reason = Str(info, "overageDisabledReason") is { } r && !string.IsNullOrWhiteSpace(r) ? r.Trim() : null; + return new RateLimitNotice( + status, + rateLimitType, + hasPrimary ? utilization : null, + resetsAt, + overageRelevant ? overageStatus : null, + overageRelevant && hasPrimary ? overageReset : null, + overageRelevant ? reason : null, + usingOverage); + } + + private static string RejectionMessage(RateLimitNotice notice) + { + var reason = notice.OverageDisabledReason is null ? "" : $"; {notice.OverageDisabledReason}"; + var reset = notice.ResetsAt is null ? "" : $"; resets at {notice.ResetsAt.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)}"; + return $"Claude rate limit rejected ({notice.RateLimitType}){reason}{reset}"; + } + + private static string FormatNotice(RateLimitNotice notice) + { + var reset = notice.ResetsAt is null ? "" : $"; resets at {notice.ResetsAt.Value.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}"; + var overageReset = notice.OverageResetsAt is null ? "" : $"; overage resets at {notice.OverageResetsAt.Value.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}"; + var overage = notice.OverageStatus is null ? "" : $"; overage {notice.OverageStatus}{(notice.OverageDisabledReason is null ? "" : $" ({notice.OverageDisabledReason})")}"; + var usingOverage = notice.IsUsingOverage ? "; using overage" : ""; + if (notice.Status == "rejected") + return $"[claude-code] Claude rate limited ({notice.RateLimitType}){reset}{overageReset}{overage}{usingOverage}"; + var usage = notice.Utilization is null ? "usage is approaching the limit" : $"{Math.Floor(notice.Utilization.Value * 100)}% used"; + return $"[claude-code] Claude rate limit warning: {usage} ({notice.RateLimitType}){reset}{overageReset}{overage}{usingOverage}"; + } + + private static string? AlertStatus(string? value) => value is "allowed_warning" or "rejected" ? value : null; + + private static DateTimeOffset? Timestamp(JsonElement info, string name) + { + if (!info.TryGetProperty(name, out var element) || element.ValueKind != JsonValueKind.Number || !element.TryGetDouble(out var value) || value <= 0 || double.IsInfinity(value)) + return null; + // The current wire value is Unix seconds; a plausible epoch-millisecond value is not converted twice. + var milliseconds = value >= EpochMillisecondsThreshold ? value : value * 1000; + try + { + return DateTimeOffset.FromUnixTimeMilliseconds((long)milliseconds); + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + + // ---- usage ---- + + private void ApplyUsage(JsonElement container) + { + if (Obj(container, "usage") is not { } usage) return; + if (Int(usage, "input_tokens") is { } input) _input = input; + if (Int(usage, "output_tokens") is { } output) _output = output; + if (Int(usage, "cache_read_input_tokens") is { } cacheRead) _cacheRead = cacheRead; + if (Int(usage, "cache_creation_input_tokens") is { } cacheWrite) _cacheWrite = cacheWrite; + if (Obj(usage, "output_tokens_details") is { } details && Int(details, "thinking_tokens") is { } reasoning) _reasoning = reasoning; + Builder.SetUsage(new Usage(_input, _output, _cacheRead, _cacheWrite, _reasoning)); + } + + private void ApplyModelUsage(JsonElement record) + { + if (Obj(record, "modelUsage") is not { } modelUsage) return; + foreach (var entry in modelUsage.EnumerateObject()) + { + if (entry.Value.ValueKind != JsonValueKind.Object) continue; + ServedContextWindow ??= Int(entry.Value, "contextWindow"); + ServedMaxOutputTokens ??= Int(entry.Value, "maxOutputTokens"); + } + } + + // ---- helpers ---- + + private static bool IsLengthStop(string? reason) => reason is "max_tokens" or "model_context_window_exceeded"; + + private static ClaudeCodeException Stream(string message) => new(ClaudeCodeException.Codes.ProtocolStream, message); + + private static ClaudeCodeException Init(string message) => new(ClaudeCodeException.Codes.ProtocolInit, message); + + private static int Index(JsonElement ev) + { + if (!ev.TryGetProperty("index", out var index) || index.ValueKind != JsonValueKind.Number || !index.TryGetInt32(out var value) || value < 0) + throw Stream($"Invalid Claude content index: {(ev.TryGetProperty("index", out var raw) ? raw.ToString() : "missing")}"); + return value; + } + + private static string? Str(JsonElement obj, string name) => + obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static JsonElement? Obj(JsonElement obj, string name) => + obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Object ? value : null; + + private static bool? Bool(JsonElement obj, string name) => + obj.ValueKind == JsonValueKind.Object && obj.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : null; + + private static int? Int(JsonElement obj, string name) + { + if (obj.ValueKind != JsonValueKind.Object || !obj.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Number) return null; + if (value.TryGetInt32(out var i)) return i; + return value.TryGetDouble(out var d) && double.IsFinite(d) ? (int)Math.Clamp(d, int.MinValue, int.MaxValue) : null; + } + + private static string? FirstNonEmpty(params string?[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v))?.Trim(); + + private static string? ErrorsText(JsonElement record) + { + if (!record.TryGetProperty("errors", out var errors) || errors.ValueKind != JsonValueKind.Array) return null; + var text = string.Join("; ", errors.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!.Trim()).Where(s => s.Length > 0)); + return text.Length == 0 ? null : text; + } + + private static string RecordKind(string? type, string? subtype) + { + static string Part(string? value) => value is null ? "" : Truncate(Sanitize().Replace(value, ""), 48); + var t = Part(type); + var s = Part(subtype); + if (t.Length == 0) t = "untyped"; + return s.Length == 0 ? t : $"{t}/{s}"; + } + + private static string InventoryNames(JsonElement entries) + { + var names = entries.EnumerateArray().Take(5).Select(entry => + { + var name = entry.ValueKind == JsonValueKind.String ? entry.GetString() : Str(entry, "name"); + if (string.IsNullOrEmpty(name)) return "unnamed"; + return name.Contains('/') || name.Contains('\\') ? "" : Truncate(name, 64); + }); + var count = entries.GetArrayLength(); + return $"{string.Join(", ", names)}{(count > 5 ? $", and {count - 5} more" : "")}"; + } + + private static string ServerStatuses(JsonElement servers) + { + var statuses = servers.EnumerateArray().Select(s => Str(s, "status")).Where(s => s is not null).Distinct().ToList(); + return statuses.Count == 0 ? "" : $" [{string.Join(", ", statuses)}]"; + } + + private static string FormatNames(IEnumerable names) + { + var joined = string.Join(", ", names.OrderBy(n => n, StringComparer.Ordinal)); + return joined.Length == 0 ? "none" : joined; + } + + private static string Truncate(string value, int max) => value.Length <= max ? value : value[..max]; + + [GeneratedRegex(@"prompt is too long|prompt too long|context window exceeded|context length exceeded|context_length_exceeded|input length", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex Overflow(); + + [GeneratedRegex(@"[^\w.-]", RegexOptions.CultureInvariant)] + private static partial Regex Sanitize(); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs new file mode 100644 index 0000000..a368843 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ToolCatalog.cs @@ -0,0 +1,145 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; +using Volcanic.AgenticCore.Abstractions.Providers; + +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// +/// The active tools as the proposal MCP server advertises them. Claude Code names an MCP tool +/// mcp__<server>__<tool> after replacing every character outside [A-Za-z0-9_-] +/// with _, so any other name gets a digest alias; otherwise its initialisation would report a +/// name this catalogue never offered. +/// +public sealed partial class ToolCatalog +{ + public const int MaxTools = 256; + public const int MaxCatalogBytes = 2 * 1024 * 1024; + public const string DefaultServerName = "volcanic"; + private const int MaxAliasLength = 48; + + private static readonly JsonWriterOptions WriterOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public ToolCatalog(IReadOnlyList tools, string serverName = DefaultServerName) + { + ArgumentNullException.ThrowIfNull(tools); + if (string.IsNullOrWhiteSpace(serverName) || !SafeName().IsMatch(serverName)) + throw new ArgumentException("MCP server names must match [A-Za-z0-9_-]{1,48}.", nameof(serverName)); + if (tools.Count > MaxTools) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"At most {MaxTools} active tools are supported"); + foreach (var tool in tools) + { + if (tool is null || string.IsNullOrWhiteSpace(tool.Name) || tool.Description is null) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Context contained an invalid active tool"); + if (tool.Parameters.ValueKind != JsonValueKind.Object) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Active tool schema for '{tool.Name}' must be an object"); + } + + ServerName = serverName; + var sorted = tools.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(); + for (var i = 1; i < sorted.Count; i++) + { + if (sorted[i - 1].Name == sorted[i].Name) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Duplicate active tool: {sorted[i].Name}"); + } + + var used = new HashSet(StringComparer.Ordinal); + var transportToTool = new Dictionary(StringComparer.Ordinal); + var toolToTransport = new Dictionary(StringComparer.Ordinal); + var aliases = new List(sorted.Count); + var transportNames = new List(sorted.Count); + foreach (var tool in sorted) + { + var alias = UniqueAlias(tool.Name, used); + var transport = $"{TransportPrefix}{alias}"; + aliases.Add(alias); + transportNames.Add(transport); + transportToTool[transport] = tool.Name; + toolToTransport[tool.Name] = transport; + } + + Tools = sorted; + Aliases = aliases; + TransportNames = transportNames; + TransportToTool = transportToTool; + ToolToTransport = toolToTransport; + Json = BuildJson(sorted, aliases); + } + + public string ServerName { get; } + + /// mcp__<server>__. + public string TransportPrefix => $"mcp__{ServerName}__"; + + /// Active tools sorted by name. + public IReadOnlyList Tools { get; } + + /// Alias of each tool in order. + public IReadOnlyList Aliases { get; } + + /// Transport name of each tool in order; exactly what Claude Code's init record must list. + public IReadOnlyList TransportNames { get; } + + public IReadOnlyDictionary TransportToTool { get; } + + public IReadOnlyDictionary ToolToTransport { get; } + + public int Count => Tools.Count; + + public bool IsEmpty => Tools.Count == 0; + + /// The catalogue file content: a JSON array of {name, description, inputSchema}. + public string Json { get; } + + public string ToJson() => Json; + + /// The alias for a tool name before collision suffixing. + public static string AliasFor(string name) => + SafeName().IsMatch(name) ? name : $"tool_{Digest16(name)}"; + + /// First 16 hex characters of SHA-256 of the UTF-8 name. + public static string Digest16(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..16]; + + private static string UniqueAlias(string name, HashSet used) + { + var candidate = AliasFor(name); + var stem = candidate; + var suffix = 2; + while (used.Contains(candidate)) + { + var tail = $"_{suffix++}"; + candidate = stem[..Math.Min(stem.Length, MaxAliasLength - tail.Length)] + tail; + } + used.Add(candidate); + return candidate; + } + + private static string BuildJson(IReadOnlyList tools, IReadOnlyList aliases) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer, WriterOptions)) + { + writer.WriteStartArray(); + for (var i = 0; i < tools.Count; i++) + { + writer.WriteStartObject(); + writer.WriteString("name", aliases[i]); + writer.WriteString("description", tools[i].Description); + writer.WritePropertyName("inputSchema"); + tools[i].Parameters.WriteTo(writer); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + if (buffer.WrittenCount + 1 > MaxCatalogBytes) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, $"Tool catalog exceeds {MaxCatalogBytes} bytes"); + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + [GeneratedRegex("^[A-Za-z0-9_-]{1,48}$", RegexOptions.CultureInvariant)] + private static partial Regex SafeName(); +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/BridgeTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/BridgeTests.cs new file mode 100644 index 0000000..5341343 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/BridgeTests.cs @@ -0,0 +1,187 @@ +using System.Text.Json; +using BridgeServer = Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.Bridge; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class BridgeTests +{ + private const string Catalog = "[{\"name\":\"probe\",\"description\":\"probe\",\"inputSchema\":{\"type\":\"object\"}}]"; + + private sealed class Sandbox : IDisposable + { + public string Directory { get; } = System.IO.Directory.CreateTempSubdirectory("bridge-test-").FullName; + public string CatalogPath => Path.Combine(Directory, "tools.json"); + public string ReadyPath => Path.Combine(Directory, "ready"); + public string ViolationPath => Path.Combine(Directory, "violation"); + + public Sandbox(string catalog = Catalog) => File.WriteAllText(CatalogPath, catalog); + + public Dictionary Env => new(StringComparer.OrdinalIgnoreCase) + { + [BridgeServer.CatalogVariable] = CatalogPath, + [BridgeServer.ReadyVariable] = ReadyPath, + [BridgeServer.ViolationVariable] = ViolationPath, + }; + + public void Dispose() => System.IO.Directory.Delete(Directory, recursive: true); + } + + private static (int Code, IReadOnlyList Responses, string Stderr) Run(string input, IReadOnlyDictionary env) + { + var output = new StringWriter(); + var error = new StringWriter(); + var code = BridgeServer.Run(new StringReader(input), output, error, env); + var responses = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(line => JsonSerializer.Deserialize(line)).ToList(); + return (code, responses, error.ToString()); + } + + private static string Request(int id, string method, string parameters = "{}") => + $"{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"{method}\",\"params\":{parameters}}}"; + + [Fact] + public void Initialize_returns_protocol_version_and_tool_capability() + { + using var sandbox = new Sandbox(); + var (code, responses, _) = Run(Request(1, "initialize") + "\n", sandbox.Env); + + Assert.Equal(0, code); + var response = Assert.Single(responses); + Assert.Equal("2.0", response.GetProperty("jsonrpc").GetString()); + Assert.Equal(1, response.GetProperty("id").GetInt32()); + var result = response.GetProperty("result"); + Assert.Equal("2025-11-25", result.GetProperty("protocolVersion").GetString()); + Assert.Equal(JsonValueKind.Object, result.GetProperty("capabilities").GetProperty("tools").ValueKind); + Assert.Equal("volcanic-tool-proposals", result.GetProperty("serverInfo").GetProperty("name").GetString()); + Assert.Equal("1.0.0", result.GetProperty("serverInfo").GetProperty("version").GetString()); + Assert.False(File.Exists(sandbox.ReadyPath)); + } + + [Fact] + public void ToolsList_returns_catalogue_and_creates_ready_marker() + { + using var sandbox = new Sandbox(); + var (code, responses, _) = Run(Request(1, "initialize") + "\n" + Request(2, "tools/list") + "\n", sandbox.Env); + + Assert.Equal(0, code); + Assert.Equal(2, responses.Count); + var tools = responses[1].GetProperty("result").GetProperty("tools"); + Assert.Equal(1, tools.GetArrayLength()); + Assert.Equal("probe", tools[0].GetProperty("name").GetString()); + Assert.Equal("object", tools[0].GetProperty("inputSchema").GetProperty("type").GetString()); + Assert.True(File.Exists(sandbox.ReadyPath)); + Assert.Equal("ready\n", File.ReadAllText(sandbox.ReadyPath)); + Assert.False(File.Exists(sandbox.ViolationPath)); + } + + [Fact] + public void ToolsCall_creates_violation_marker_and_refuses_execution() + { + using var sandbox = new Sandbox(); + var (code, responses, _) = Run(Request(3, "tools/call", "{\"name\":\"probe\",\"arguments\":{}}") + "\n" + Request(4, "tools/call") + "\n", sandbox.Env); + + Assert.Equal(0, code); + Assert.Equal(2, responses.Count); + foreach (var response in responses) + { + var error = response.GetProperty("error"); + Assert.Equal(-32001, error.GetProperty("code").GetInt32()); + Assert.Contains("never executes", error.GetProperty("message").GetString()); + } + Assert.True(File.Exists(sandbox.ViolationPath)); + } + + [Fact] + public void Unknown_method_is_method_not_found() + { + using var sandbox = new Sandbox(); + var (code, responses, _) = Run(Request(5, "future/method") + "\n", sandbox.Env); + + Assert.Equal(0, code); + var error = Assert.Single(responses).GetProperty("error"); + Assert.Equal(-32601, error.GetProperty("code").GetInt32()); + Assert.Equal("Method not found: future/method", error.GetProperty("message").GetString()); + } + + [Fact] + public void Notification_without_id_is_ignored() + { + using var sandbox = new Sandbox(); + var (code, responses, _) = Run("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\"}\n", sandbox.Env); + + Assert.Equal(0, code); + Assert.Empty(responses); + // A notification-style tools/list still marks readiness. + Assert.True(File.Exists(sandbox.ReadyPath)); + } + + [Fact] + public void Invalid_json_and_non_object_requests_are_rejected_without_stopping() + { + using var sandbox = new Sandbox(); + var (code, responses, _) = Run("not json\n[1,2]\n" + Request(1, "initialize") + "\n", sandbox.Env); + + Assert.Equal(0, code); + Assert.Equal(3, responses.Count); + Assert.Equal(-32700, responses[0].GetProperty("error").GetProperty("code").GetInt32()); + Assert.Equal(JsonValueKind.Null, responses[0].GetProperty("id").ValueKind); + Assert.Equal(-32600, responses[1].GetProperty("error").GetProperty("code").GetInt32()); + Assert.True(responses[2].TryGetProperty("result", out _)); + } + + [Fact] + public void Missing_or_invalid_catalogue_returns_2() + { + var (code, responses, stderr) = Run(Request(1, "initialize") + "\n", new Dictionary()); + Assert.Equal(2, code); + Assert.Empty(responses); + Assert.Contains(BridgeServer.CatalogVariable, stderr); + + using var invalid = new Sandbox("not json"); + var (invalidCode, _, invalidStderr) = Run(Request(1, "initialize") + "\n", invalid.Env); + Assert.Equal(2, invalidCode); + Assert.Contains("invalid tool catalog", invalidStderr); + + using var notArray = new Sandbox("{\"tools\":[]}"); + var (objectCode, _, objectStderr) = Run("", notArray.Env); + Assert.Equal(2, objectCode); + Assert.Contains("must be an array", objectStderr); + } + + [Fact] + public void Crlf_lines_and_unterminated_last_line_are_accepted() + { + using var sandbox = new Sandbox(); + var (code, responses, _) = Run(Request(1, "initialize", "{\"note\":\"雪\"}") + "\r\n" + Request(2, "tools/list"), sandbox.Env); + + Assert.Equal(0, code); + Assert.Equal(2, responses.Count); + Assert.Equal(1, responses[0].GetProperty("id").GetInt32()); + Assert.Equal(2, responses[1].GetProperty("id").GetInt32()); + Assert.True(File.Exists(sandbox.ReadyPath)); + } + + [Fact] + public void Request_over_one_megabyte_fails_with_parse_error_and_exit_1() + { + using var sandbox = new Sandbox(); + var empty = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"future\",\"pad\":\"\"}"; + var exact = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"future\",\"pad\":\"" + new string('x', BridgeServer.MaxRequestBytes - empty.Length) + "\"}"; + Assert.Equal(BridgeServer.MaxRequestBytes, System.Text.Encoding.UTF8.GetByteCount(exact)); + + // Exactly the limit is accepted (and answered as an unknown method). + var (okCode, okResponses, _) = Run(exact + "\n", sandbox.Env); + Assert.Equal(0, okCode); + Assert.Equal(-32601, Assert.Single(okResponses).GetProperty("error").GetProperty("code").GetInt32()); + + // One byte over is fatal, even without a terminating newline. + var (code, responses, _) = Run(new string('x', BridgeServer.MaxRequestBytes + 1), sandbox.Env); + Assert.Equal(1, code); + var error = Assert.Single(responses).GetProperty("error"); + Assert.Equal(-32700, error.GetProperty("code").GetInt32()); + Assert.Contains("exceeds the bridge limit", error.GetProperty("message").GetString()); + + var (terminatedCode, terminatedResponses, _) = Run(new string('x', BridgeServer.MaxRequestBytes + 1) + "\n" + Request(9, "initialize") + "\n", sandbox.Env); + Assert.Equal(1, terminatedCode); + Assert.Single(terminatedResponses); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs new file mode 100644 index 0000000..63d8e83 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class ClaudeArgsTests +{ + [Fact] + public void Build_with_tools_and_effort_yields_the_exact_argv() + { + var input = new ClaudeArgsInput( + "sonnet", "medium", "/tmp/private/system-prompt.txt", + CatalogPath: "/tmp/private/tools.json", ViolationPath: "/tmp/private/mcp-violation", ReadyPath: "/tmp/private/mcp-ready", + BridgeDllPath: "/opt/ext/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll", DotnetPath: "/usr/bin/dotnet"); + + var args = ClaudeArgs.Build(input); + + var mcp = ClaudeArgs.BuildMcpConfig(input); + string[] expected = + [ + "-p", + "--setting-sources", "", + "--settings", "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}", + "--disable-slash-commands", + "--strict-mcp-config", + "--permission-mode", "dontAsk", + "--no-chrome", + "--no-session-persistence", + "--prompt-suggestions", "false", + "--mcp-config", mcp, + "--tools", "", + "--model", "sonnet", + "--effort", "medium", + "--thinking-display", "summarized", + "--input-format", "stream-json", + "--output-format", "stream-json", + "--include-partial-messages", + "--verbose", + "--system-prompt-file", "/tmp/private/system-prompt.txt", + ]; + Assert.Equal(expected, args); + + var server = JsonSerializer.Deserialize(mcp).GetProperty("mcpServers").GetProperty("volcanic"); + Assert.Equal("/usr/bin/dotnet", server.GetProperty("command").GetString()); + Assert.Equal(["/opt/ext/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll"], server.GetProperty("args").EnumerateArray().Select(a => a.GetString()!).ToArray()); + var env = server.GetProperty("env"); + Assert.Equal("/tmp/private/tools.json", env.GetProperty("VOLCANIC_CLAUDE_TOOL_CATALOG").GetString()); + Assert.Equal("/tmp/private/mcp-violation", env.GetProperty("VOLCANIC_CLAUDE_TOOL_VIOLATION").GetString()); + Assert.Equal("/tmp/private/mcp-ready", env.GetProperty("VOLCANIC_CLAUDE_TOOL_READY").GetString()); + } + + [Fact] + public void Build_without_tools_or_effort_uses_an_empty_mcp_config_and_omits_effort() + { + var args = ClaudeArgs.Build(new ClaudeArgsInput("haiku", null, "/tmp/p/system-prompt.txt")); + + string[] expected = + [ + "-p", "--setting-sources", "", "--settings", ClaudeArgs.Settings, "--disable-slash-commands", "--strict-mcp-config", + "--permission-mode", "dontAsk", "--no-chrome", "--no-session-persistence", "--prompt-suggestions", "false", + "--mcp-config", "{\"mcpServers\":{}}", "--tools", "", "--model", "haiku", + "--thinking-display", "summarized", "--input-format", "stream-json", "--output-format", "stream-json", + "--include-partial-messages", "--verbose", "--system-prompt-file", "/tmp/p/system-prompt.txt", + ]; + Assert.Equal(expected, args); + Assert.DoesNotContain("--effort", args); + } + + [Fact] + public void Mcp_config_honours_the_server_name_and_requires_bridge_and_dotnet() + { + var mcp = ClaudeArgs.BuildMcpConfig(new ClaudeArgsInput("sonnet", null, "/p/s.txt", CatalogPath: "/p/tools.json", BridgeDllPath: "/b.dll", DotnetPath: "dotnet", ServerName: "pi")); + Assert.True(JsonSerializer.Deserialize(mcp).GetProperty("mcpServers").TryGetProperty("pi", out _)); + + var missingBridge = Assert.Throws(() => ClaudeArgs.BuildMcpConfig(new ClaudeArgsInput("sonnet", null, "/p/s.txt", CatalogPath: "/p/tools.json", DotnetPath: "dotnet"))); + Assert.Equal("mcp_startup", missingBridge.Code); + } + + [Fact] + public void BuildEnvironment_allow_lists_forces_and_rejects() + { + var parent = new Dictionary + { + ["HOME"] = "/home/me", + ["PATH"] = "/usr/bin", + ["LANG"] = "C.UTF-8", + ["LC_ALL"] = "C", + ["LC_CTYPE"] = "C.UTF-8", + ["http_proxy"] = "http://proxy", + ["CLAUDE_CONFIG_DIR"] = "/cfg", + ["SECRET_TOKEN"] = "nope", + ["NPM_TOKEN"] = "nope", + ["DISABLE_COMPACT"] = "0", + ["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = "1", + ["NULL_VALUE"] = null, + }; + + var env = ClaudeArgs.BuildEnvironment(parent, 64_000); + + Assert.Equal("/home/me", env["HOME"]); + Assert.Equal("/usr/bin", env["PATH"]); + Assert.Equal("C", env["LC_ALL"]); + Assert.Equal("C.UTF-8", env["LC_CTYPE"]); + Assert.Equal("http://proxy", env["http_proxy"]); + Assert.Equal("/cfg", env["CLAUDE_CONFIG_DIR"]); + Assert.False(env.ContainsKey("SECRET_TOKEN")); + Assert.False(env.ContainsKey("NPM_TOKEN")); + Assert.False(env.ContainsKey("NULL_VALUE")); + Assert.Equal("1", env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"]); + Assert.Equal("1", env["CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS"]); + Assert.Equal("1", env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]); + Assert.Equal("1", env["DISABLE_COMPACT"]); + Assert.Equal("64000", env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"]); + } + + [Theory] + [InlineData("ANTHROPIC_API_KEY")] + [InlineData("ANTHROPIC_AUTH_TOKEN")] + [InlineData("ANTHROPIC_BASE_URL")] + [InlineData("CLAUDE_CODE_OAUTH_TOKEN")] + [InlineData("CLAUDE_CODE_USE_BEDROCK")] + [InlineData("claude_code_use_vertex")] + public void BuildEnvironment_rejects_api_key_and_third_party_auth(string name) + { + var parent = new Dictionary { ["HOME"] = "/home/me", [name] = "value" }; + var ex = Assert.Throws(() => ClaudeArgs.BuildEnvironment(parent, 1000)); + Assert.Equal("auth", ex.Code); + Assert.Contains(name, ex.Message); + Assert.Equal(name, ClaudeArgs.FindDeniedVariable(parent)); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs new file mode 100644 index 0000000..1bfae76 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs @@ -0,0 +1,221 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +/// Drives end to end against a fake claude shell script. No test ever spawns the real CLI. +public class ClaudeCodeApiTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("claude-api-test-").FullName; + private readonly string _cwd; + private readonly SessionRegistry _registry = new(); + + public ClaudeCodeApiTests() + { + _cwd = Path.Combine(_root, "project"); + Directory.CreateDirectory(_cwd); + _registry.Register("session-1", _cwd); + } + + public void Dispose() + { + _registry.Remove("session-1"); + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + } + + private static string BridgeDll => Path.Combine(AppContext.BaseDirectory, BridgeLocator.BridgeFileName); + + /// A fake claude: touches the ready marker (found through the --system-prompt-file argument), prints the body, then runs the tail. + private string FakeClaude(string body, string tail = "exec sleep 30") + { + var script = Path.Combine(_root, $"fake-claude-{Guid.NewGuid():N}.sh"); + File.WriteAllText(script, "#!/bin/sh\n" + + "prev=\"\"\ndir=\"\"\n" + + "for a in \"$@\"; do\n" + + " if [ \"$prev\" = \"--system-prompt-file\" ]; then dir=$(dirname \"$a\"); fi\n" + + " prev=\"$a\"\n" + + "done\n" + + "if [ -n \"$dir\" ]; then : > \"$dir/mcp-ready\"; fi\n" + + body + "\n" + tail + "\n"); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return script; + } + + private ClaudeCodeApi Api(string claudePath) => new(_registry, new ClaudeCodeApiOptions + { + ClaudePath = claudePath, + BridgeDllPath = BridgeDll, + DotnetPath = Environment.ProcessPath ?? "dotnet", + GetEnv = _ => null, + ParentEnvironment = new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH"), ["HOME"] = _root }, + ServerName = Fixtures.ServerName, + }); + + private static async Task> Collect(IAsyncEnumerable stream) + { + var events = new List(); + await foreach (var e in stream) events.Add(e); + return events; + } + + private static LlmContext ToolContext() => new("You are a test.", [UserMessage.FromText("run the probe")], [Fixtures.Probe]); + + [Fact] + public async Task Tool_proposal_is_captured_and_the_process_is_terminated_at_the_latch() + { + if (!Fixtures.IsPosix) return; + var script = FakeClaude($"cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\""); + var api = Api(script); + var stopwatch = Stopwatch.StartNew(); + + var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1", Reasoning = ThinkingLevel.Medium }, CancellationToken.None)); + stopwatch.Stop(); + + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(8), $"the sleeping fake claude must be terminated promptly; took {stopwatch.Elapsed}"); + Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray()); + var done = Assert.IsType(events[^1]); + Assert.Equal(StopReason.ToolUse, done.Reason); + var call = Assert.Single(done.Message.ToolCalls); + Assert.Equal("probe", call.Name); + Assert.Equal("probe", call.Arguments.GetProperty("path").GetString()); + Assert.Equal("calling", done.Message.Text); + Assert.Equal(20, done.Message.Usage.Input); + Assert.Equal("claude-code", done.Message.Provider); + Assert.Equal("sonnet", done.Message.Model); + // The private request directory is removed before the terminal event is published. + Assert.DoesNotContain(Directory.GetDirectories(Path.GetTempPath(), "volcanic-claude-*"), + d => File.Exists(Path.Combine(d, "system-prompt.txt")) && File.ReadAllText(Path.Combine(d, "system-prompt.txt")) == "You are a test."); + } + + [Fact] + public async Task Exit_before_a_terminal_event_reports_the_stderr_excerpt() + { + if (!Fixtures.IsPosix) return; + var script = FakeClaude("echo \"boom: fake failure\" >&2", tail: "exit 1"); + var api = Api(script); + + var events = await Collect(api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("sys", "hi"), new StreamOptions { SessionId = "session-1" }, CancellationToken.None)); + + var error = Assert.IsType(Assert.Single(events)); + Assert.Equal(StopReason.Error, error.Reason); + Assert.StartsWith("[process_exit]", error.Message.ErrorMessage); + Assert.Contains("code 1", error.Message.ErrorMessage); + Assert.Contains("boom: fake failure", error.Message.ErrorMessage); + } + + [Fact] + public async Task Text_only_turn_without_tools_completes_and_runs_the_hooks() + { + if (!Fixtures.IsPosix) return; + var lines = new[] + { + Fixtures.Init().GetRawText(), + "{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_t\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":7}}}}", + "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}", + "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello there\"}}}", + "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}", + "{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}}", + "{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}", + "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello there\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":7,\"output_tokens\":3}}", + }; + var fixture = Path.Combine(_root, "text.jsonl"); + File.WriteAllLines(fixture, lines); + // Echo stdin back into a file so the request payload can be checked, then print the response. + var script = FakeClaude($"cat > \"{Path.Combine(_root, "stdin.json")}\"\ncat \"{fixture}\"", tail: "exit 0"); + var api = Api(script); + JsonNode? payload = null; + var responses = new List(); + var records = 0; + var options = new StreamOptions + { + SessionId = "session-1", + OnPayload = p => { payload = p; return ValueTask.FromResult(null); }, + OnResponse = r => { responses.Add(r); return ValueTask.CompletedTask; }, + OnProviderStreamEvent = _ => { records++; return ValueTask.CompletedTask; }, + }; + + var events = await Collect(api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple("system prompt", "hi @there"), options, CancellationToken.None)); + + Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "Done"], events.Select(e => e.GetType().Name).ToArray()); + var done = Assert.IsType(events[^1]); + Assert.Equal(StopReason.Stop, done.Reason); + Assert.Equal("hello there", done.Message.Text); + Assert.Equal(7, done.Message.Usage.Input); + Assert.Equal(3, done.Message.Usage.Output); + Assert.Equal(lines.Length, records); + Assert.Equal(200, Assert.Single(responses).Status); + + Assert.NotNull(payload); + var argv = payload["argv"]!.AsArray().Select(a => a!.GetValue()).ToList(); + Assert.Contains("--system-prompt-file", argv); + Assert.Equal("sonnet", argv[argv.IndexOf("--model") + 1]); + Assert.DoesNotContain("--effort", argv); + Assert.Equal("{\"mcpServers\":{}}", argv[argv.IndexOf("--mcp-config") + 1]); + + var stdin = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(_root, "stdin.json"))); + Assert.Equal("user", stdin.GetProperty("type").GetString()); + var blocks = stdin.GetProperty("message").GetProperty("content"); + Assert.Equal(2, blocks.GetArrayLength()); + Assert.DoesNotContain("@", blocks[1].GetProperty("text").GetString()); + Assert.Contains("hi \\u0040there", blocks[1].GetProperty("text").GetString()); + Assert.True(blocks[1].TryGetProperty("cache_control", out _)); + } + + [Fact] + public async Task Cancellation_terminates_the_process_and_reports_aborted() + { + if (!Fixtures.IsPosix) return; + var script = FakeClaude($"echo '{Fixtures.Init().GetRawText()}'"); + var api = Api(script); + using var cts = new CancellationTokenSource(); + var events = new List(); + var stopwatch = Stopwatch.StartNew(); + + await foreach (var e in api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { SessionId = "session-1" }, cts.Token)) + { + events.Add(e); + if (e is AssistantMessageEvent.Start) cts.CancelAfter(100); + } + stopwatch.Stop(); + + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(8), $"took {stopwatch.Elapsed}"); + Assert.Equal(["Start", "Error"], events.Select(e => e.GetType().Name).ToArray()); + var error = Assert.IsType(events[^1]); + Assert.Equal(StopReason.Aborted, error.Reason); + Assert.Equal("aborted", error.Message.ErrorMessage); + } + + [Fact] + public async Task Missing_executable_and_unresolvable_session_fail_without_throwing() + { + var missing = new ClaudeCodeApi(_registry, new ClaudeCodeApiOptions { ClaudePath = null, GetEnv = _ => null }); + var events = await Collect(missing.StreamAsync(Fixtures.Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { SessionId = "session-1" }, CancellationToken.None)); + var error = Assert.IsType(Assert.Single(events)); + Assert.StartsWith("[executable_missing]", error.Message.ErrorMessage); + + if (!Fixtures.IsPosix) return; + var api = Api(FakeClaude("", tail: "exit 0")); + var unresolved = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "unknown-session" }, CancellationToken.None)); + Assert.StartsWith("[working_directory]", Assert.IsType(Assert.Single(unresolved)).Message.ErrorMessage); + } + + [Fact] + public async Task Api_key_in_the_parent_environment_is_refused() + { + if (!Fixtures.IsPosix) return; + var api = new ClaudeCodeApi(_registry, new ClaudeCodeApiOptions + { + ClaudePath = FakeClaude("", tail: "exit 0"), + GetEnv = _ => null, + ParentEnvironment = new Dictionary { ["ANTHROPIC_API_KEY"] = "sk-x" }, + }); + + var events = await Collect(api.StreamAsync(Fixtures.Sonnet, LlmContext.Simple(null, "hi"), new StreamOptions { SessionId = "session-1" }, CancellationToken.None)); + + Assert.StartsWith("[auth]", Assert.IsType(Assert.Single(events)).Message.ErrorMessage); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeInstallationTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeInstallationTests.cs new file mode 100644 index 0000000..99fe9a9 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeInstallationTests.cs @@ -0,0 +1,109 @@ +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class ClaudeInstallationTests +{ + [Fact] + public void Captured_help_has_every_required_flag() + { + Assert.Empty(ClaudeInstallation.MissingFlags(Fixtures.HelpText)); + Assert.Contains("--print", ClaudeInstallation.RequiredFlags); + Assert.Contains("--system-prompt", ClaudeInstallation.RequiredFlags); + } + + [Fact] + public void Truncated_help_reports_the_missing_flags() + { + var lines = Fixtures.HelpText.Split('\n'); + var truncated = string.Join('\n', lines.Take(60)); + + var missing = ClaudeInstallation.MissingFlags(truncated); + + Assert.NotEmpty(missing); + Assert.Contains("--verbose", missing); + Assert.Contains("--tools", missing); + Assert.DoesNotContain("--add-dir", missing); + Assert.Empty(ClaudeInstallation.MissingFlags(" -p, --print x\n" + string.Join('\n', ClaudeInstallation.RequiredFlags.Select(f => $" {f} help")))); + // A longer flag with the same prefix does not satisfy the check. + Assert.Contains("--tools", ClaudeInstallation.MissingFlags(" --toolset ")); + } + + [Fact] + public void Locate_prefers_the_environment_override_then_searches_path() + { + Assert.Equal("/opt/claude/bin/claude", ClaudeInstallation.Locate(name => name == ClaudeInstallation.PathVariable ? " /opt/claude/bin/claude " : null)); + Assert.Null(ClaudeInstallation.Locate(_ => null)); + + var directory = Directory.CreateTempSubdirectory("locate-test-").FullName; + try + { + var name = OperatingSystem.IsWindows() ? "claude.cmd" : "claude"; + var executable = Path.Combine(directory, name); + File.WriteAllText(executable, "#!/bin/sh\nexit 0\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite); + Assert.Null(ClaudeInstallation.Locate(n => n == "PATH" ? directory : null)); + File.SetUnixFileMode(executable, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + Assert.Equal(executable, ClaudeInstallation.Locate(n => n switch + { + "PATH" => $"{Path.Combine(directory, "missing")}{Path.PathSeparator}{directory}", + "PATHEXT" => ".COM;.EXE;.BAT;.CMD", + _ => null, + })); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task Inspect_reads_version_auth_and_help_from_the_cli() + { + if (!Fixtures.IsPosix) return; + var directory = Directory.CreateTempSubdirectory("inspect-test-").FullName; + try + { + var script = Path.Combine(directory, "claude"); + File.WriteAllText(script, $$""" + #!/bin/sh + case "$1" in + --version) echo "2.1.280 (Claude Code)";; + auth) echo '{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"Pro"}';; + --help) cat "{{Fixtures.FixturePath("claude-2.1.281-help.txt")}}";; + *) echo "unexpected: $*" >&2; exit 3;; + esac + + """); + if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var info = await ClaudeInstallation.InspectAsync(script, CancellationToken.None); + + Assert.Equal(script, info.Path); + Assert.Equal("2.1.280", info.Version); + Assert.True(info.LoggedIn); + Assert.Equal("claude.ai", info.AuthMethod); + Assert.Equal("firstParty", info.ApiProvider); + Assert.Equal("pro", info.SubscriptionType); + Assert.Empty(info.MissingFlags); + Assert.DoesNotContain(info.Warnings, w => w.Contains("version")); + + File.WriteAllText(script, "#!/bin/sh\necho 'claude: not logged in'\nexit 1\n"); + var broken = await ClaudeInstallation.InspectAsync(script, CancellationToken.None); + Assert.Null(broken.Version); + Assert.False(broken.LoggedIn); + Assert.Equal(ClaudeInstallation.RequiredFlags.Count, broken.MissingFlags.Count); + Assert.NotEmpty(broken.Warnings); + } + finally + { + Directory.Delete(directory, recursive: true); + } + + var missing = await Assert.ThrowsAsync(() => ClaudeInstallation.InspectAsync(Path.Combine(directory, "nope"), CancellationToken.None)); + Assert.Equal("executable_missing", missing.Code); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeProcessTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeProcessTests.cs new file mode 100644 index 0000000..c34e461 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeProcessTests.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class ClaudeProcessTests +{ + private static ClaudeProcessOptions Sleep(TimeSpan idle, TimeSpan total) => new( + "/bin/sh", ["-c", "echo started; exec sleep 30"], Path.GetTempPath(), + new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" }, idle, total); + + [Fact] + public async Task Terminate_sends_sigterm_first_and_reports_143() + { + if (!Fixtures.IsPosix) return; + using var process = ClaudeProcess.Start(Sleep(TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(30))); + var line = new byte[8]; + Assert.True(await process.Stdout.ReadAsync(line) > 0); + var stopwatch = Stopwatch.StartNew(); + + await process.TerminateAsync(); + var exitCode = await process.WaitForExitAsync(CancellationToken.None); + + Assert.True(process.TerminationRequested); + Assert.True(process.HasExited); + Assert.Equal(143, exitCode); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3), $"took {stopwatch.Elapsed}"); + Assert.Null(process.TimeoutError); + } + + [Fact] + public async Task Idle_timeout_terminates_the_child_and_records_a_timeout_error() + { + if (!Fixtures.IsPosix) return; + using var process = ClaudeProcess.Start(Sleep(TimeSpan.FromMilliseconds(300), TimeSpan.FromMinutes(30))); + + var exitCode = await process.WaitForExitAsync(CancellationToken.None); + + Assert.NotNull(process.TimeoutError); + Assert.Equal("timeout", process.TimeoutError.Code); + Assert.Contains("no protocol activity", process.TimeoutError.Message); + Assert.NotEqual(0, exitCode); + } + + [Fact] + public async Task Stderr_tail_is_bounded_and_sanitised() + { + if (!Fixtures.IsPosix) return; + var options = new ClaudeProcessOptions("/bin/sh", ["-c", "printf 'x%.0s' $(seq 1 70000) >&2; printf 'tail\\tline\\n' >&2; exit 3"], Path.GetTempPath(), + new Dictionary { ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "/usr/bin:/bin" }, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + using var process = ClaudeProcess.Start(options); + + var exitCode = await process.WaitForExitAsync(CancellationToken.None); + + Assert.Equal(3, exitCode); + var excerpt = process.StderrExcerpt(maxChars: 20); + Assert.EndsWith("tail line", excerpt); + Assert.True(excerpt.Length <= 20); + Assert.True(process.StderrExcerpt(maxChars: 100_000).Length <= ClaudeProcess.MaxStderrChars); + } + + [Fact] + public void Missing_executable_is_reported_as_executable_missing() + { + var ex = Assert.Throws(() => ClaudeProcess.Start(new ClaudeProcessOptions( + Path.Combine(Path.GetTempPath(), "no-such-claude"), [], Path.GetTempPath(), new Dictionary(), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)))); + Assert.Equal("executable_missing", ex.Code); + } + + [Fact] + public void Timeout_settings_parse_or_fail_loudly() + { + Assert.Equal(TimeSpan.FromMinutes(5), ClaudeProcess.TimeoutSetting(_ => null, ClaudeProcess.IdleTimeoutVariable, TimeSpan.FromMinutes(5))); + Assert.Equal(TimeSpan.FromMilliseconds(1500), ClaudeProcess.TimeoutSetting(_ => " 1500 ", ClaudeProcess.IdleTimeoutVariable, TimeSpan.FromMinutes(5))); + Assert.Equal("timeout", Assert.Throws(() => ClaudeProcess.TimeoutSetting(_ => "-1", ClaudeProcess.IdleTimeoutVariable, TimeSpan.Zero)).Code); + Assert.Equal("timeout", Assert.Throws(() => ClaudeProcess.TimeoutSetting(_ => "soon", ClaudeProcess.IdleTimeoutVariable, TimeSpan.Zero)).Code); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs new file mode 100644 index 0000000..4d106d6 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ContextSerializerTests.cs @@ -0,0 +1,216 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class ContextSerializerTests : IDisposable +{ + private readonly ClaudeImageStore _images = new(); + + public void Dispose() => _images.Dispose(); + + private static AssistantMessage Assistant(params ContentBlock[] content) => new() + { + Content = content, + Provider = "claude-code", + Api = "claude-code-headless", + Model = "sonnet", + StopReason = StopReason.Stop, + Usage = new Usage(1, 1, 0, 0), + }; + + private static ImageContent Pixels(string text) => new(Convert.ToBase64String(Encoding.UTF8.GetBytes(text)), "image/png"); + + private static IReadOnlyList Blocks(JsonObject stdin) => + stdin["message"]!["content"]!.AsArray().Select(b => JsonSerializer.Deserialize(b!.ToJsonString())).ToList(); + + private static JsonElement ParseRecord(JsonElement block) => JsonSerializer.Deserialize(block.GetProperty("text").GetString()!); + + [Fact] + public void Header_then_one_record_per_message_inside_one_user_message() + { + var context = new LlmContext( + "system is passed separately", + [ + UserMessage.FromText("hello"), + Assistant(new ThinkingContent("thought", "opaque-signature"), new TextContent("hi"), new ToolCallContent("call-1", "probe", Fixtures.Record("{\"path\":\"a\"}"))), + new ToolResultMessage { ToolCallId = "call-1", ToolName = "probe", Content = [new TextContent("result")], IsError = false }, + ], + [Fixtures.Probe]); + + var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: false); + + Assert.Equal("user", stdin["type"]!.GetValue()); + Assert.Equal("user", stdin["message"]!["role"]!.GetValue()); + var blocks = Blocks(stdin); + Assert.Equal(4, blocks.Count); + Assert.All(blocks, b => Assert.Equal("text", b.GetProperty("type").GetString())); + + var header = ParseRecord(blocks[0]); + Assert.Equal("volcanic-claude-code-context-v1", header.GetProperty("protocol").GetString()); + Assert.Contains("Continue this conversation", header.GetProperty("instruction").GetString()); + var map = Assert.Single(header.GetProperty("toolNameMap").EnumerateArray()); + Assert.Equal("mcp__pi__probe", map.GetProperty("transportName").GetString()); + Assert.Equal("probe", map.GetProperty("toolName").GetString()); + + var user = ParseRecord(blocks[1]); + Assert.Equal("user", user.GetProperty("role").GetString()); + Assert.Equal("hello", user.GetProperty("content").GetString()); + + var assistant = ParseRecord(blocks[2]); + Assert.Equal("assistant", assistant.GetProperty("role").GetString()); + var content = assistant.GetProperty("content"); + Assert.Equal("thinking", content[0].GetProperty("type").GetString()); + Assert.Equal("thought", content[0].GetProperty("thinking").GetString()); + Assert.False(content[0].TryGetProperty("signature", out _)); + Assert.False(content[0].TryGetProperty("redacted", out _)); + Assert.Equal("hi", content[1].GetProperty("text").GetString()); + Assert.Equal("toolCall", content[2].GetProperty("type").GetString()); + Assert.Equal("call-1", content[2].GetProperty("id").GetString()); + Assert.Equal("mcp__pi__probe", content[2].GetProperty("name").GetString()); + Assert.Equal("a", content[2].GetProperty("arguments").GetProperty("path").GetString()); + Assert.False(assistant.TryGetProperty("usage", out _)); + + var result = ParseRecord(blocks[3]); + Assert.Equal("toolResult", result.GetProperty("role").GetString()); + Assert.Equal("call-1", result.GetProperty("toolCallId").GetString()); + Assert.Equal("mcp__pi__probe", result.GetProperty("toolName").GetString()); + Assert.Equal("result", result.GetProperty("content")[0].GetProperty("text").GetString()); + Assert.False(result.GetProperty("isError").GetBoolean()); + } + + [Fact] + public void At_signs_are_escaped_so_claude_cannot_expand_paths() + { + var context = new LlmContext(null, [UserMessage.FromText("Do not expand @/etc/passwd"), Assistant(new TextContent("mail me@example.com"))], []); + + var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false); + + foreach (var block in Blocks(stdin)) + { + var text = block.GetProperty("text").GetString()!; + Assert.DoesNotContain("@", text); + Assert.Contains(text.Contains("passwd") || text.Contains("example") ? "\\u0040" : "protocol", text); + } + Assert.Equal("Do not expand @/etc/passwd", ParseRecord(Blocks(stdin)[1]).GetProperty("content").GetString()); + Assert.Equal("mail me@example.com", ParseRecord(Blocks(stdin)[2]).GetProperty("content")[0].GetProperty("text").GetString()); + } + + [Fact] + public void Cache_control_marks_only_the_last_block_and_only_when_requested() + { + var context = new LlmContext(null, [UserMessage.FromText("one"), Assistant(new TextContent("two")), UserMessage.FromText("three")], []); + + var marked = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: true)); + Assert.Equal(4, marked.Count); + for (var i = 0; i < 3; i++) Assert.False(marked[i].TryGetProperty("cache_control", out _)); + var control = marked[3].GetProperty("cache_control"); + Assert.Equal("ephemeral", control.GetProperty("type").GetString()); + Assert.Equal("1h", control.GetProperty("ttl").GetString()); + + var unmarked = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false)); + Assert.All(unmarked, b => Assert.False(b.TryGetProperty("cache_control", out _))); + } + + [Fact] + public void Images_become_attachments_referenced_after_the_breakpoint() + { + var image = Pixels("png!"); + var context = new LlmContext(null, + [ + new UserMessage { Content = [new TextContent("look"), image] }, + Assistant(new ToolCallContent("c1", "probe", Fixtures.Record("{}"))), + new ToolResultMessage { ToolCallId = "c1", ToolName = "probe", Content = [Pixels("tool result"), image], IsError = false }, + ], + [Fixtures.Probe]); + + var stdin = ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: true); + var blocks = Blocks(stdin); + + // header + 3 records + the attachment instruction + Assert.Equal(5, blocks.Count); + var user = ParseRecord(blocks[1]).GetProperty("content"); + Assert.Equal("image_attachment", user[1].GetProperty("type").GetString()); + Assert.Matches("^image-[a-f0-9]{64}\\.png$", user[1].GetProperty("attachment").GetString()); + Assert.Equal("image/png", user[1].GetProperty("mimeType").GetString()); + Assert.True(blocks[3].TryGetProperty("cache_control", out _), "the breakpoint sits on the last transcript record"); + Assert.False(blocks[4].TryGetProperty("cache_control", out _), "the attachment list stays outside the breakpoint"); + + var instruction = blocks[4].GetProperty("text").GetString()!; + Assert.StartsWith("Generated image attachments for image_attachment blocks: @\"", instruction); + var transcript = ContextSerializer.Serialize(context, Fixtures.ProbeCatalog, _images); + Assert.Equal(2, transcript.AttachmentPaths.Count); + Assert.Equal(3, transcript.ImageCount); + foreach (var path in transcript.AttachmentPaths) + { + Assert.True(Path.IsPathRooted(path)); + Assert.True(File.Exists(path)); + Assert.Contains($"@\"{path}\"", instruction); + } + Assert.Equal("png!", File.ReadAllText(transcript.AttachmentPaths[0])); + } + + [Fact] + public void Tools_outside_the_catalogue_get_the_unavailable_label_and_results_follow_their_call() + { + var context = new LlmContext(null, + [ + Assistant(new ToolCallContent("c1", "removed tool", Fixtures.Record("{\"x\":1}")), new ToolCallContent("c2", "probe", Fixtures.Record("{}"))), + new ToolResultMessage { ToolCallId = "c1", ToolName = "stale name", Content = [new TextContent("one")], IsError = true }, + new ToolResultMessage { ToolCallId = "orphan", ToolName = "orphaned tool", Content = [new TextContent("two")], IsError = false }, + ], + [Fixtures.Probe]); + + var blocks = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.ProbeCatalog, _images, cacheBreakpoint: false)); + var assistant = ParseRecord(blocks[1]).GetProperty("content"); + Assert.Equal("[unavailable tool removed tool]", assistant[0].GetProperty("name").GetString()); + Assert.Equal("mcp__pi__probe", assistant[1].GetProperty("name").GetString()); + Assert.Equal("[unavailable tool removed tool]", ParseRecord(blocks[2]).GetProperty("toolName").GetString()); + Assert.True(ParseRecord(blocks[2]).GetProperty("isError").GetBoolean()); + Assert.Equal("[unavailable tool orphaned tool]", ParseRecord(blocks[3]).GetProperty("toolName").GetString()); + } + + [Fact] + public void Custom_messages_are_user_turns_and_empty_assistant_messages_are_dropped() + { + var context = new LlmContext(null, + [CustomMessage.FromText("note", "steer"), Assistant(), new UserMessage { Content = [new TextContent("a"), new TextContent("b")] }], + []); + + var blocks = Blocks(ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false)); + Assert.Equal(3, blocks.Count); + Assert.Equal("steer", ParseRecord(blocks[1]).GetProperty("content").GetString()); + Assert.Equal(2, ParseRecord(blocks[2]).GetProperty("content").GetArrayLength()); + } + + [Fact] + public void Transcript_over_32_megabytes_is_a_context_overflow() + { + var context = new LlmContext(null, [UserMessage.FromText(new string('x', ContextSerializer.MaxTranscriptBytes + 1024))], []); + + var ex = Assert.Throws(() => ContextSerializer.BuildStdinMessage(context, Fixtures.EmptyCatalog, _images, cacheBreakpoint: false)); + Assert.Equal("context_overflow", ex.Code); + Assert.StartsWith("context_length_exceeded: ", ex.Message); + Assert.StartsWith("context_length_exceeded: ", ex.FormatForUser()); + } + + [Fact] + public void Invalid_images_and_misplaced_blocks_are_rejected() + { + var badBase64 = new LlmContext(null, [new UserMessage { Content = [new ImageContent("%%%", "image/png")] }], []); + Assert.Equal("image", Assert.Throws(() => ContextSerializer.Serialize(badBase64, Fixtures.EmptyCatalog, _images)).Code); + + var badType = new LlmContext(null, [new UserMessage { Content = [new ImageContent("AA==", "image/bmp")] }], []); + Assert.Equal("image", Assert.Throws(() => ContextSerializer.Serialize(badType, Fixtures.EmptyCatalog, _images)).Code); + + var thinkingInUser = new LlmContext(null, [new UserMessage { Content = [new ThinkingContent("leaked")] }], []); + Assert.Equal("content_shape", Assert.Throws(() => ContextSerializer.Serialize(thinkingInUser, Fixtures.EmptyCatalog, _images)).Code); + + var tooMany = new LlmContext(null, [new UserMessage { Content = Enumerable.Range(0, 21).Select(i => (ContentBlock)Pixels($"img {i}")).ToList() }], []); + Assert.Contains("At most 20 images", Assert.Throws(() => ContextSerializer.Serialize(tooMany, Fixtures.EmptyCatalog, _images)).Message); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/DoctorCommandTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/DoctorCommandTests.cs new file mode 100644 index 0000000..08fd813 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/DoctorCommandTests.cs @@ -0,0 +1,62 @@ +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class DoctorCommandTests +{ + private static string BridgeDll => Path.Combine(AppContext.BaseDirectory, BridgeLocator.BridgeFileName); + + private static string? Dotnet => BridgeLocator.FindDotnet(Environment.GetEnvironmentVariable); + + [Fact] + public void Bridge_locator_finds_the_bridge_next_to_the_host_and_a_dotnet_muxer() + { + Assert.Equal(BridgeDll, BridgeLocator.FindBridgeDll()); + Assert.True(File.Exists(Path.ChangeExtension(BridgeDll, ".runtimeconfig.json")), "the bridge runtimeconfig must travel with the dll"); + var dotnet = Dotnet; + Assert.NotNull(dotnet); + Assert.True(File.Exists(dotnet)); + // Without PATH or DOTNET_ROOT the only source is the current process, which must then be the muxer itself. + if (BridgeLocator.FindDotnet(_ => null) is { } fromProcess) + Assert.Equal("dotnet", Path.GetFileNameWithoutExtension(fromProcess), ignoreCase: true); + } + + [Fact] + public async Task Bridge_probe_completes_a_real_handshake_against_the_built_bridge() + { + var probe = await DoctorCommand.ProbeBridgeAsync(Dotnet, BridgeDll, TimeSpan.FromSeconds(30), CancellationToken.None); + + Assert.True(probe.Ok, probe.Detail); + Assert.Equal("handshake completed: 1 tool listed, ready marker written", probe.Detail); + Assert.Equal([Dotnet!, BridgeDll], probe.Argv); + } + + [Fact] + public async Task Bridge_probe_reports_missing_pieces() + { + var noDotnet = await DoctorCommand.ProbeBridgeAsync(null, BridgeDll, TimeSpan.FromSeconds(5), CancellationToken.None); + Assert.False(noDotnet.Ok); + Assert.Contains("dotnet", noDotnet.Detail); + + var noBridge = await DoctorCommand.ProbeBridgeAsync(Dotnet, Path.Combine(AppContext.BaseDirectory, "missing.dll"), TimeSpan.FromSeconds(5), CancellationToken.None); + Assert.False(noBridge.Ok); + Assert.Contains("not found", noBridge.Detail); + + var notABridge = await DoctorCommand.ProbeBridgeAsync(Dotnet, Path.Combine(AppContext.BaseDirectory, "Volcanic.AgenticCore.Extensions.ClaudeCode.dll"), TimeSpan.FromSeconds(30), CancellationToken.None); + Assert.False(notABridge.Ok); + } + + [Fact] + public async Task Report_lists_the_cli_status_the_bridge_and_the_models() + { + var registry = new SessionRegistry(); + var (report, ok) = await DoctorCommand.BuildReportAsync(new ClaudeCodeApiOptions { ClaudePath = null, BridgeDllPath = BridgeDll, DotnetPath = Dotnet, GetEnv = _ => null, ParentEnvironment = new Dictionary() }, registry, CancellationToken.None); + + Assert.False(ok); + Assert.Contains("# Claude Code doctor", report); + Assert.Contains("Claude Code: **not found**", report); + Assert.Contains("Bridge: ok", report); + Assert.Contains("Models: sonnet, opus, haiku, fable", report); + Assert.Contains("Registered sessions: 0", report); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures.cs new file mode 100644 index 0000000..97be752 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/Fixtures.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +/// Captured Claude Code 2.1.281 output (see Fixtures/README.md) and the catalogue those captures were made with. +internal static class Fixtures +{ + /// The captures were taken with the original transport prefix mcp__pi__. + public const string ServerName = "pi"; + + public static string Directory => Path.Combine(AppContext.BaseDirectory, "ClaudeCode", "Fixtures"); + + public static string FixturePath(string name) => Path.Combine(Directory, name); + + public static string HelpText => File.ReadAllText(FixturePath("claude-2.1.281-help.txt")); + + public static IReadOnlyList StreamLines(string scenario) => + File.ReadAllLines(FixturePath($"claude-2.1.281-stream-{scenario}.jsonl")).Where(line => line.Trim().Length > 0).ToList(); + + public static IReadOnlyList StreamRecords(string scenario) => StreamLines(scenario).Select(Record).ToList(); + + public static JsonElement Record(string json) => JsonSerializer.Deserialize(json); + + public static ToolDescriptor Probe => new("probe", "probe", Record("{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}")); + + public static ToolCatalog ProbeCatalog => new([Probe], ServerName); + + public static ToolCatalog EmptyCatalog => new([], ServerName); + + public static Model Sonnet => ClaudeCodeModels.Sonnet; + + /// An init record in the fixtures' shape with the given tools and MCP servers. + public static JsonElement Init(string tools = "[]", string mcpServers = "[]", string apiKeySource = "none", string permissionMode = "dontAsk") => Record( + $$"""{"type":"system","subtype":"init","cwd":"/project","session_id":"s","tools":{{tools}},"mcp_servers":{{mcpServers}},"model":"claude-sonnet-5","permissionMode":"{{permissionMode}}","slash_commands":[],"apiKeySource":"{{apiKeySource}}","skills":[],"plugins":[]}"""); + + public static JsonElement ProbeInit(string apiKeySource = "none", string mcpServers = "[{\"name\":\"pi\",\"status\":\"connected\"}]") => + Init("[\"mcp__pi__probe\"]", mcpServers, apiKeySource); + + public static bool IsPosix => OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD(); +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/JsonlReaderTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/JsonlReaderTests.cs new file mode 100644 index 0000000..a55f7c0 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/JsonlReaderTests.cs @@ -0,0 +1,83 @@ +using System.Text; +using System.Text.Json; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class JsonlReaderTests +{ + /// Delivers at most bytes per read so framing across chunk boundaries is exercised. + private sealed class TrickleStream(byte[] data, int chunk) : Stream + { + private int _position; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => data.Length; + public override long Position { get => _position; set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) + { + var n = Math.Min(chunk, Math.Min(count, data.Length - _position)); + Array.Copy(data, _position, buffer, offset, n); + _position += n; + return n; + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + private static async Task> ReadAll(string text, int chunk = 3, int max = JsonlReader.DefaultMaxRecordBytes) + { + var records = new List(); + await foreach (var record in JsonlReader.ReadAsync(new TrickleStream(Encoding.UTF8.GetBytes(text), chunk), max)) + records.Add(record); + return records; + } + + [Fact] + public async Task Splits_on_lf_strips_one_cr_and_accepts_an_unterminated_last_line() + { + var records = await ReadAll("{\"text\":\"snowman ☃\"}\r\n{\"n\":2}\n\n \n{\"c\":3}"); + + Assert.Equal(3, records.Count); + Assert.Equal("snowman ☃", records[0].GetProperty("text").GetString()); + Assert.Equal(2, records[1].GetProperty("n").GetInt32()); + Assert.Equal(3, records[2].GetProperty("c").GetInt32()); + } + + [Fact] + public async Task Does_not_split_on_unicode_line_separators_inside_strings() + { + // Raw U+2028 / U+2029 bytes inside a JSON string are ordinary content, not record boundaries. + var lineSeparator = char.ConvertFromUtf32(0x2028); + var paragraphSeparator = char.ConvertFromUtf32(0x2029); + var text = "a" + lineSeparator + "b" + paragraphSeparator + "c"; + + var records = await ReadAll("{\"text\":\"" + text + "\"}\n", chunk: 1); + + Assert.Equal(text, Assert.Single(records).GetProperty("text").GetString()); + } + + [Fact] + public async Task Malformed_lines_fail_with_protocol_stream() + { + var ex = await Assert.ThrowsAsync(() => ReadAll("nope\n")); + Assert.Equal("protocol_stream", ex.Code); + Assert.Contains("malformed", ex.Message); + } + + [Fact] + public async Task Oversized_records_are_rejected_before_and_at_the_newline() + { + var unterminated = await Assert.ThrowsAsync(() => ReadAll("123456", chunk: 2, max: 4)); + Assert.Contains("oversized", unterminated.Message); + + var terminated = await Assert.ThrowsAsync(() => ReadAll("12345\n", chunk: 6, max: 4)); + Assert.Contains("oversized", terminated.Message); + + var exact = await ReadAll("1234\r\n", chunk: 6, max: 4); + Assert.Equal(1234, Assert.Single(exact).GetInt32()); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs new file mode 100644 index 0000000..c396045 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ProviderExtensionTests.cs @@ -0,0 +1,193 @@ +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 +{ + /// Records registrations; every action throws so a misuse during activation is loud. + private sealed class FakeExtensionApi : IExtensionApi + { + public List Providers { get; } = []; + public Dictionary Commands { get; } = new(); + public Dictionary 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(ExtensionHandler 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? images = null, MessageDelivery delivery = MessageDelivery.Steer) => throw new NotSupportedException(); + public void AppendEntry(string customType, JsonElement? data = null) => throw new NotSupportedException(); + public Task 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 GetActiveTools() => throw new NotSupportedException(); + public void SetActiveTools(IEnumerable names) => throw new NotSupportedException(); + public IReadOnlyList GetAllTools() => throw new NotSupportedException(); + public IReadOnlyList GetCommands() => throw new NotSupportedException(); + public string? GetSessionName() => throw new NotSupportedException(); + public void SetSessionName(string? name) => throw new NotSupportedException(); + public Task ExecAsync(string command, IReadOnlyList args, ExecOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public ValueTask Raise(TEvent e, IExtensionContext ctx) where TEvent : ExtensionEvent => + ((ExtensionHandler)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 NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new SwitchResult(false)); + public Task 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 SelectAsync(string title, IReadOnlyList options, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(null); + public Task ConfirmAsync(string title, string message, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(false); + public Task InputAsync(string title, string? placeholder, UiRequestOptions? requestOptions, CancellationToken cancellationToken) => Task.FromResult(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(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); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/SessionRegistryTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/SessionRegistryTests.cs new file mode 100644 index 0000000..3897ac6 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/SessionRegistryTests.cs @@ -0,0 +1,151 @@ +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class SessionRegistryTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("registry-test-").FullName; + private readonly SessionRegistry _registry = new(); + + public void Dispose() => Directory.Delete(_root, recursive: true); + + private string Dir(string name) + { + var path = Path.Combine(_root, name); + Directory.CreateDirectory(path); + return path; + } + + private static LlmContext Context(string? systemPrompt) => new(systemPrompt, [], []); + + private static string CoreStyle(string cwd) => $"You are an assistant.\n\n\nCurrent date: 2026-09-25\nWorking directory: {cwd}\n"; + + private static string CwdTag(string cwd) => $"\nhi\n\n\n\n{cwd}\n\n\n\ncontext\n"; + + [Fact] + public void Registered_session_wins_and_reports_source_session() + { + var a = Dir("a"); + var b = Dir("b"); + _registry.Register("s-a", a); + _registry.Register("s-b", b); + + var resolved = _registry.Resolve("s-a", Context(CwdTag(a)), hasTools: true); + + Assert.Equal(a, resolved.Cwd); + Assert.Equal("session", resolved.Source); + Assert.Same(_registry.Get("s-a")!.Images, resolved.Images); + Assert.Equal(2, _registry.Count); + } + + [Fact] + public void Prompt_cwd_tag_resolves_an_unknown_session_and_borrows_a_matching_store() + { + var parent = Dir("parent"); + var child = Dir("child"); + _registry.Register("s-parent", parent); + _registry.Register("s-child-dir", child); + + var resolved = _registry.Resolve("unknown", Context(CwdTag(child)), hasTools: true); + + Assert.Equal(child, resolved.Cwd); + Assert.Equal("prompt", resolved.Source); + Assert.Same(_registry.Get("s-child-dir")!.Images, resolved.Images); + } + + [Fact] + public void Trailing_working_directory_line_in_the_core_prompt_is_recognised() + { + var project = Dir("project"); + var resolved = _registry.Resolve(null, Context(CoreStyle(project)), hasTools: true); + + Assert.Equal(project, resolved.Cwd); + Assert.Equal("prompt", resolved.Source); + Assert.NotNull(resolved.Images); + Assert.Equal(project, SessionRegistry.PromptWorkingDirectory($"intro\nCurrent working directory: {project}")); + Assert.Equal(project, SessionRegistry.PromptWorkingDirectory($"intro\nWorking directory: {project}\n")); + } + + [Fact] + public void Tool_free_requests_borrow_the_newest_session() + { + var older = Dir("older"); + var newer = Dir("newer"); + _registry.Register("s-1", older); + _registry.Register("s-2", newer); + + var resolved = _registry.Resolve("fresh-compaction-id", Context("Summarise the conversation."), hasTools: false); + + Assert.Equal(newer, resolved.Cwd); + Assert.Equal("oneshot", resolved.Source); + Assert.Same(_registry.Get("s-2")!.Images, resolved.Images); + + // Re-registering an older session makes it the newest. + _registry.Register("s-1", older); + Assert.Equal(older, _registry.Resolve(null, Context(null), hasTools: false).Cwd); + } + + [Fact] + public void Tool_bearing_requests_without_a_recognised_directory_are_refused() + { + _registry.Register("s-1", Dir("one")); + + var ex = Assert.Throws(() => _registry.Resolve("fresh", Context("no directory here"), hasTools: true)); + Assert.Equal("working_directory", ex.Code); + Assert.Contains("1 session(s) are live", ex.Message); + + var empty = new SessionRegistry(); + Assert.Equal("working_directory", Assert.Throws(() => empty.Resolve(null, Context(null), hasTools: false)).Code); + } + + [Fact] + public void Registered_session_whose_prompt_names_another_directory_fails_closed() + { + var a = Dir("a"); + var b = Dir("b"); + _registry.Register("s-a", a); + + var ex = Assert.Throws(() => _registry.Resolve("s-a", Context(CwdTag(b)), hasTools: true)); + Assert.Equal("working_directory", ex.Code); + Assert.Contains("another session's directory", ex.Message); + + // Separator and trailing-slash differences are not a conflict. + Assert.Equal(a, _registry.Resolve("s-a", Context(CwdTag(a + Path.DirectorySeparatorChar)), hasTools: true).Cwd); + } + + [Fact] + public void Missing_or_relative_directories_are_refused() + { + _registry.Register("s-gone", Path.Combine(_root, "gone")); + Assert.Equal("working_directory", Assert.Throws(() => _registry.Resolve("s-gone", Context(null), hasTools: true)).Code); + + Assert.Equal("working_directory", Assert.Throws(() => _registry.Resolve(null, Context(CwdTag("relative/dir")), hasTools: true)).Code); + } + + [Fact] + public void Repository_authored_cwd_sections_are_ignored_and_the_last_statement_wins() + { + var injected = "\n\n\n/srv/attacker\n\n\n\n\n" + CwdTag("/srv/a/project"); + Assert.Equal("/srv/a/project", SessionRegistry.PromptWorkingDirectory(injected)); + + var trailing = CwdTag("/srv/a/project") + "\n\n\n\n/srv/attacker\n\n"; + Assert.Equal("/srv/a/project", SessionRegistry.PromptWorkingDirectory(trailing)); + + Assert.Null(SessionRegistry.PromptWorkingDirectory("\n\n/srv/attacker\n\n")); + Assert.Equal("/srv/late", SessionRegistry.PromptWorkingDirectory(CwdTag("/srv/early") + "\nWorking directory: /srv/late")); + Assert.Equal("/srv/late", SessionRegistry.PromptWorkingDirectory("Working directory: /srv/early\n\n" + CwdTag("/srv/late"))); + Assert.Null(SessionRegistry.PromptWorkingDirectory("You are an assistant.")); + Assert.Null(SessionRegistry.PromptWorkingDirectory(null)); + } + + [Fact] + public void Remove_disposes_the_image_store() + { + var entry = _registry.Register("s", Dir("s")); + Assert.True(_registry.Remove("s")); + Assert.False(_registry.Remove("s")); + Assert.Null(_registry.Get("s")); + Assert.Throws(() => entry.Images.Add(new Abstractions.Messages.ImageContent("AA==", "image/png"))); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs new file mode 100644 index 0000000..d12d628 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/StreamEventMapperTests.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class StreamEventMapperTests +{ + private sealed record Replay(StreamEventMapper Mapper, List Events, ClaudeCodeException? Rejected, AssistantMessageEvent? Terminal); + + /// Feeds every captured record the way the provider does: later stream events are ignored after a latch, and the terminal is produced after the result. + private static Replay ReplayCapture(string scenario, ToolCatalog? catalog = null) + { + var mapper = new StreamEventMapper(Fixtures.Sonnet, catalog ?? Fixtures.ProbeCatalog); + var events = new List(); + ClaudeCodeException? rejected = null; + foreach (var record in Fixtures.StreamRecords(scenario)) + { + try + { + events.AddRange(mapper.Accept(record)); + } + catch (ClaudeCodeException ex) + { + rejected = ex; + break; + } + } + AssistantMessageEvent? terminal = null; + if (rejected is null && (mapper.HandoffLatched || mapper.HasSuccessfulResult)) + { + terminal = mapper.Complete(); + events.Add(terminal); + } + return new Replay(mapper, events, rejected, terminal); + } + + private static string[] Kinds(IEnumerable events) => events.Select(e => e.GetType().Name).ToArray(); + + [Fact] + public void Tool_ok_capture_maps_text_then_the_proposal_and_ignores_everything_after_the_latch() + { + var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog); + var records = Fixtures.StreamRecords("tool-ok"); + var events = new List(); + var latchedAt = -1; + for (var i = 0; i < records.Count; i++) + { + events.AddRange(mapper.Accept(records[i])); + if (mapper.HandoffLatched && latchedAt < 0) latchedAt = i; + } + + Assert.Equal( + ["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd"], + Kinds(events)); + Assert.Equal("calling", Assert.IsType(events[2]).Delta); + Assert.Equal("probe", Assert.IsType(events[4]).ToolName); + Assert.Equal("toolu_ok", Assert.IsType(events[4]).Id); + var end = Assert.IsType(events[6]); + Assert.Equal("probe", end.ToolCall.Name); + Assert.Equal("probe", end.ToolCall.Arguments.GetProperty("path").GetString()); + + // The latch happens at the tool_use message_delta; the second turn Claude Code starts afterwards produces nothing. + Assert.True(latchedAt > 0); + Assert.Contains("\"stop_reason\":\"tool_use\"", Fixtures.StreamLines("tool-ok")[latchedAt]); + Assert.Equal(StopReason.ToolUse, mapper.PendingStop); + Assert.True(mapper.SawResult); + Assert.Equal("claude-sonnet-5", mapper.ResponseModel); + + var done = Assert.IsType(mapper.Complete()); + Assert.Equal(StopReason.ToolUse, done.Reason); + Assert.Equal(StopReason.ToolUse, done.Message.StopReason); + Assert.Equal(20, done.Message.Usage.Input); + Assert.Equal(4, done.Message.Usage.Output); + Assert.Equal("calling", done.Message.Text); + Assert.Single(done.Message.ToolCalls); + Assert.Equal("msg_1", done.Message.ResponseId); + } + + [Fact] + public void Max_tokens_capture_is_a_length_stop_that_keeps_the_text() + { + var replay = ReplayCapture("max-tokens"); + + Assert.Null(replay.Rejected); + var done = Assert.IsType(replay.Terminal); + Assert.Equal(StopReason.Length, done.Reason); + Assert.Equal("truncated by limit", done.Message.Text); + Assert.Single(replay.Events.OfType()); + Assert.Single(replay.Events.OfType()); + } + + [Fact] + public void Context_window_exceeded_capture_is_a_recoverable_length_stop() + { + // The API stops generation at the window with model_context_window_exceeded; Claude Code answers it like max_tokens. + var replay = ReplayCapture("context-window-exceeded"); + + Assert.Null(replay.Rejected); + var done = Assert.IsType(replay.Terminal); + Assert.Equal(StopReason.Length, done.Reason); + Assert.Equal("cut off by the window", done.Message.Text); + } + + [Fact] + public void Prompt_too_long_capture_is_a_context_overflow() + { + var replay = ReplayCapture("prompt-too-long"); + + Assert.NotNull(replay.Rejected); + var ex = replay.Rejected; + Assert.Equal("context_overflow", ex.Code); + Assert.StartsWith("context_length_exceeded: ", ex.Message); + Assert.Contains("Prompt is too long", ex.Message); + Assert.Contains("(400)", ex.Message); + Assert.StartsWith("context_length_exceeded:", ex.FormatForUser()); + Assert.False(ex.Retryable); + } + + [Theory] + [InlineData("drop", "began retrying")] + [InlineData("drop-tool", "mid-response API error")] + [InlineData("drop-text-stop", "continue a cut-off response")] + [InlineData("sse-error", "non-streaming request")] + [InlineData("drop-partial-tool", "began retrying")] + public void Mid_response_recoveries_fail_retryably(string scenario, string cause) + { + var replay = ReplayCapture(scenario); + + Assert.NotNull(replay.Rejected); + var ex = replay.Rejected; + Assert.Equal("stream_interrupted", ex.Code); + Assert.True(ex.Retryable); + Assert.Contains("stream ended before message_stop", ex.Message); + Assert.Contains(cause, ex.Message); + Assert.Contains("stream ended before message_stop", ex.FormatForUser()); + // Nothing already streamed is rewritten: no content index starts twice and Start is published once. + var starts = replay.Events.Where(e => e is AssistantMessageEvent.TextStart or AssistantMessageEvent.ThinkingStart or AssistantMessageEvent.ToolCallStart) + .Select(e => e switch + { + AssistantMessageEvent.TextStart t => t.ContentIndex, + AssistantMessageEvent.ThinkingStart t => t.ContentIndex, + AssistantMessageEvent.ToolCallStart t => t.ContentIndex, + _ => -1, + }).ToList(); + Assert.Equal(starts.Distinct().ToList(), starts); + Assert.Single(replay.Events.OfType()); + Assert.DoesNotContain(replay.Events, e => e.IsTerminal); + } + + [Fact] + public void Partial_tool_arguments_hold_a_deferred_failure_until_a_recovery_signal_claims_it() + { + var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog); + var records = Fixtures.StreamRecords("drop-partial-tool"); + // init, status, message_start, block start, truncated delta, block stop, message_stop + var events = records.Take(7).SelectMany(mapper.Accept).ToList(); + + Assert.Equal(["Start", "ToolCallStart", "ToolCallDelta"], Kinds(events)); + Assert.Contains("invalid arguments for tool probe", mapper.DeferredError); + var ex = Assert.Throws(() => mapper.Accept(records[7])); + Assert.Equal("stream_interrupted", ex.Code); + } + + [Fact] + public void Pre_stream_retry_and_post_stop_drop_do_not_fail_ordinary_turns() + { + var before = ReplayCapture("http529"); + Assert.Null(before.Rejected); + Assert.Equal(StopReason.Stop, Assert.IsType(before.Terminal).Reason); + Assert.Equal("answer after a pre-stream retry", before.Mapper.Builder.Current.Text); + + var after = ReplayCapture("post-stop"); + Assert.Null(after.Rejected); + Assert.Equal(StopReason.Stop, Assert.IsType(after.Terminal).Reason); + Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "Done"], Kinds(after.Events)); + Assert.Equal(10, after.Mapper.Usage.Input); + Assert.Equal(2, after.Mapper.Usage.Output); + } + + [Fact] + public void Init_validation_rejects_api_key_auth_tool_mismatch_and_disconnected_mcp() + { + var auth = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(Fixtures.ProbeInit(apiKeySource: "user"))); + Assert.Equal("auth", auth.Code); + Assert.Contains("subscription auth", auth.Message); + + var mismatch = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.ProbeInit())); + Assert.Equal("protocol_init", mismatch.Code); + Assert.Contains("unexpected tool set", mismatch.Message); + + var builtIn = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(Fixtures.Init("[\"mcp__pi__probe\",\"Bash\"]", "[{\"name\":\"pi\",\"status\":\"connected\"}]"))); + Assert.Equal("protocol_init", builtIn.Code); + + var failed = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog).Accept(Fixtures.ProbeInit(mcpServers: "[{\"name\":\"pi\",\"status\":\"failed\"}]"))); + Assert.Equal("mcp_startup", failed.Code); + + var wrongName = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, new ToolCatalog([Fixtures.Probe], "volcanic")).Accept(Fixtures.Init("[\"mcp__volcanic__probe\"]", "[{\"name\":\"pi\",\"status\":\"connected\"}]"))); + Assert.Equal("mcp_startup", wrongName.Code); + + var unexpectedServer = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.Init("[]", "[{\"name\":\"other\",\"status\":\"connected\"}]"))); + Assert.Equal("mcp_startup", unexpectedServer.Code); + + var permission = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.Init(permissionMode: "default"))); + Assert.Equal("protocol_init", permission.Code); + + var ok = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog); + Assert.Equal(["Start"], Kinds(ok.Accept(Fixtures.ProbeInit()))); + Assert.True(ok.SawInit); + Assert.Equal("protocol_init", Assert.Throws(() => ok.Accept(Fixtures.ProbeInit())).Code); + } + + [Fact] + public void Records_before_init_unknown_records_and_unknown_tools_are_rejected() + { + var early = Assert.Throws(() => new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog).Accept(Fixtures.Record("{\"type\":\"system\",\"subtype\":\"commands_changed\"}"))); + Assert.Equal("protocol_init", early.Code); + Assert.Contains("system/commands_changed record before initialization", early.Message); + + var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog); + mapper.Accept(Fixtures.ProbeInit()); + Assert.Equal("protocol_stream", Assert.Throws(() => mapper.Accept(Fixtures.Record("{\"type\":\"future_protocol_record\"}"))).Code); + Assert.Equal("protocol_stream", Assert.Throws(() => mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}"))).Code); + + mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"model\":\"claude-sonnet-5\",\"usage\":{}}}}")); + var unknown = Assert.Throws(() => mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"t\",\"name\":\"mcp__pi__other\",\"input\":{}}}}"))); + Assert.Equal("tool_unknown", unknown.Code); + } + + [Fact] + public void Result_only_fallback_text_and_error_results() + { + var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog); + mapper.Accept(Fixtures.Init()); + var events = mapper.Accept(Fixtures.Record("{\"type\":\"result\",\"is_error\":false,\"result\":\"fallback\",\"stop_reason\":\"max_tokens\",\"usage\":{\"input_tokens\":4,\"output_tokens\":2,\"cache_read_input_tokens\":3,\"cache_creation_input_tokens\":5,\"output_tokens_details\":{\"thinking_tokens\":2}},\"modelUsage\":{\"sonnet\":{\"contextWindow\":200000,\"maxOutputTokens\":64000}}}")); + + Assert.Equal(["TextStart", "TextDelta", "TextEnd"], Kinds(events)); + Assert.True(mapper.HasSuccessfulResult); + Assert.Equal(new Usage(4, 2, 3, 5, 2), mapper.Usage); + Assert.Equal(200000, mapper.ServedContextWindow); + Assert.Equal(64000, mapper.ServedMaxOutputTokens); + var done = Assert.IsType(mapper.Complete()); + Assert.Equal(StopReason.Length, done.Reason); + Assert.Equal("fallback", done.Message.Text); + Assert.Throws(() => mapper.Complete()); + + var failing = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog); + failing.Accept(Fixtures.Init()); + var ex = Assert.Throws(() => failing.Accept(Fixtures.Record("{\"type\":\"result\",\"is_error\":true,\"api_error_status\":429,\"result\":\"subscription limit reached\"}"))); + Assert.Equal("protocol_stream", ex.Code); + Assert.Contains("(429)", ex.Message); + Assert.Contains("subscription limit reached", ex.Message); + + var diagnostic = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog); + diagnostic.Accept(Fixtures.Init()); + diagnostic.Accept(Fixtures.Record("{\"type\":\"assistant\",\"error\":\"rate_limit\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"You're out of usage credits\"}]}}")); + var kept = Assert.Throws(() => diagnostic.Accept(Fixtures.Record("{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"api_error_status\":429,\"result\":\"\",\"errors\":[\"loop detail\"]}"))); + Assert.Contains("You're out of usage credits", kept.Message); + + var malformed = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog); + malformed.Accept(Fixtures.Init()); + Assert.Contains("boolean is_error", Assert.Throws(() => malformed.Accept(Fixtures.Record("{\"type\":\"result\",\"result\":\"missing flag\"}"))).Message); + } + + private static StreamEventMapper ReadyToolMapper(string stopReason = "tool_use") + { + var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.ProbeCatalog); + mapper.Accept(Fixtures.ProbeInit()); + mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool_ack\",\"model\":\"claude-sonnet-5\",\"usage\":{}}}}")); + mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_ack\",\"name\":\"mcp__pi__probe\",\"input\":{}}}}")); + mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"path\\\":\\\"package.json\\\"}\"}}}")); + mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}")); + mapper.Accept(Fixtures.Record($"{{\"type\":\"stream_event\",\"event\":{{\"type\":\"message_delta\",\"delta\":{{\"stop_reason\":\"{stopReason}\"}},\"usage\":{{}}}}}}")); + return mapper; + } + + [Fact] + public void Handoff_termination_result_is_accepted_and_other_late_errors_are_not() + { + var mapper = ReadyToolMapper(); + Assert.True(mapper.HandoffLatched); + var kill = Fixtures.Record("{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"is_error\":true,\"stop_reason\":\"tool_use\",\"terminal_reason\":\"aborted_streaming\",\"usage\":{\"input_tokens\":4,\"output_tokens\":2},\"modelUsage\":{\"sonnet\":{\"contextWindow\":1000000,\"maxOutputTokens\":64000}}}"); + Assert.Empty(mapper.Accept(kill)); + Assert.Equal(4, mapper.Usage.Input); + Assert.Equal(1000000, mapper.ServedContextWindow); + var done = Assert.IsType(mapper.Complete()); + Assert.Equal(StopReason.ToolUse, done.Reason); + Assert.Equal("package.json", done.Message.ToolCalls[0].Arguments.GetProperty("path").GetString()); + Assert.Equal("protocol_stream", Assert.Throws(() => mapper.Accept(kill)).Code); + + // A retry Claude Code starts for its own next request must not invalidate the proposal. + var retrying = ReadyToolMapper(); + Assert.Empty(retrying.Accept(Fixtures.Record("{\"type\":\"system\",\"subtype\":\"api_retry\",\"error\":\"unknown\",\"error_status\":null}"))); + Assert.Equal(StopReason.ToolUse, Assert.IsType(retrying.Complete()).Reason); + + var realError = ReadyToolMapper(); + var ex = Assert.Throws(() => realError.Accept(Fixtures.Record("{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"is_error\":true,\"stop_reason\":\"tool_use\",\"result\":\"real_error\"}"))); + Assert.Contains("real_error", ex.Message); + + var length = ReadyToolMapper("max_tokens"); + Assert.Equal(StopReason.Length, length.PendingStop); + Assert.Equal(StopReason.Length, Assert.IsType(length.Complete()).Reason); + } + + [Fact] + public void Thinking_redacted_thinking_and_rate_limits_are_mapped() + { + var mapper = new StreamEventMapper(Fixtures.Sonnet, Fixtures.EmptyCatalog); + var events = new List(mapper.Accept(Fixtures.Init())); + mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed\"}}")); + mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed_warning\",\"rateLimitType\":\"five_hour\",\"utilization\":0.876}}")); + mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed\",\"overageStatus\":\"rejected\",\"overageDisabledReason\":\"org_level_disabled\",\"isUsingOverage\":false}}")); + mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"rejected\",\"rateLimitType\":\"five_hour\",\"resetsAt\":1800000000}}")); + mapper.Accept(Fixtures.Record("{\"type\":\"rate_limit_event\"}")); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"model\":\"claude-sonnet-5\",\"usage\":{\"input_tokens\":10}}}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"plan\"}}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig\"}}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":0}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"redacted_thinking\",\"data\":\"opaque\"}}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"ping\"}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_stop\",\"index\":1}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"stream_event\",\"event\":{\"type\":\"message_stop\"}}"))); + events.AddRange(mapper.Accept(Fixtures.Record("{\"type\":\"result\",\"is_error\":false,\"result\":\"\"}"))); + + Assert.Equal(["Start", "ThinkingStart", "ThinkingDelta", "ThinkingEnd", "ThinkingStart", "ThinkingEnd"], Kinds(events)); + var done = Assert.IsType(mapper.Complete()); + var thinking = Assert.IsType(done.Message.Content[0]); + Assert.Equal("plan", thinking.Thinking); + Assert.Equal("sig", thinking.Signature); + Assert.False(thinking.Redacted); + var redacted = Assert.IsType(done.Message.Content[1]); + Assert.True(redacted.Redacted); + Assert.Equal("opaque", redacted.Signature); + Assert.Equal(new Usage(10, 5, 0, 0), done.Message.Usage); + + Assert.Equal(2, mapper.Notices.Count); + Assert.Contains("87% used", mapper.Notices[0]); + Assert.Contains("five_hour", mapper.Notices[1]); + Assert.Contains("five_hour", mapper.RateLimitFailure); + Assert.Contains("2027-", mapper.RateLimitFailure); + // A rejected rate limit is a diagnostic, not a held failure: the successful result above still completed. + Assert.Null(mapper.DeferredError); + } + + [Fact] + public void Overflow_normalisation_prefixes_claude_wordings_once() + { + Assert.Equal("context_length_exceeded: API Error: Prompt is too long", StreamEventMapper.NormalizeOverflowMessage("API Error: Prompt is too long")); + Assert.Equal("context_length_exceeded: x", StreamEventMapper.NormalizeOverflowMessage("context_length_exceeded: x")); + Assert.Equal("rate limited", StreamEventMapper.NormalizeOverflowMessage("rate limited")); + Assert.True(StreamEventMapper.IsOverflow("input length and max_tokens exceed the context limit")); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ToolCatalogTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ToolCatalogTests.cs new file mode 100644 index 0000000..78a30d4 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ToolCatalogTests.cs @@ -0,0 +1,117 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Extensions.ClaudeCode; + +namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode; + +public class ToolCatalogTests +{ + private static readonly JsonElement Schema = Fixtures.Record("{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}}}"); + + private static ToolDescriptor Tool(string name, string description = "d") => new(name, description, Schema); + + private static string Digest16(string name) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(name)))[..16]; + + [Fact] + public void Invalid_names_get_a_digest_alias() + { + var catalog = new ToolCatalog([Tool("bad name!")]); + var alias = Assert.Single(catalog.Aliases); + Assert.Equal($"tool_{Digest16("bad name!")}", alias); + Assert.Matches("^tool_[a-f0-9]{16}$", alias); + Assert.Equal($"mcp__volcanic__{alias}", Assert.Single(catalog.TransportNames)); + Assert.Equal("bad name!", catalog.TransportToTool[$"mcp__volcanic__{alias}"]); + } + + [Fact] + public void Valid_names_are_kept_and_the_alias_regex_matches_claude_codes_rules() + { + Assert.Equal("read_file", ToolCatalog.AliasFor("read_file")); + Assert.Equal("Bash-2", ToolCatalog.AliasFor("Bash-2")); + Assert.StartsWith("tool_", ToolCatalog.AliasFor("foo.bar")); + Assert.StartsWith("tool_", ToolCatalog.AliasFor(new string('a', 49))); + Assert.Equal(new string('a', 48), ToolCatalog.AliasFor(new string('a', 48))); + } + + [Fact] + public void Collisions_are_suffixed_and_every_alias_stays_unique() + { + // A valid name that equals another tool's digest alias collides with it; the later (sorted) one gets _2. + var digest = $"tool_{Digest16("bad name!")}"; + var catalog = new ToolCatalog([Tool(digest), Tool("bad name!"), Tool(digest + "_2")]); + + Assert.Equal(["bad name!", digest, digest + "_2"], catalog.Tools.Select(t => t.Name).ToArray()); + Assert.Equal([digest, digest + "_2", digest + "_2_2"], catalog.Aliases.ToArray()); + Assert.Equal(3, catalog.TransportToTool.Count); + Assert.Equal("bad name!", catalog.TransportToTool[$"mcp__volcanic__{digest}"]); + Assert.Equal(digest, catalog.TransportToTool[$"mcp__volcanic__{digest}_2"]); + Assert.Equal(digest + "_2", catalog.TransportToTool[$"mcp__volcanic__{digest}_2_2"]); + Assert.All(catalog.Aliases, alias => Assert.True(alias.Length <= 48)); + } + + [Fact] + public void Suffixes_keep_long_aliases_within_the_limit() + { + var longName = new string('a', 48); + var catalog = new ToolCatalog([Tool(longName), Tool(longName + "!")]); + + // "aaa...!" is invalid (49 chars) and gets a digest, so no collision; both aliases are within 48 chars. + Assert.Equal(2, catalog.Aliases.Distinct().Count()); + Assert.All(catalog.Aliases, alias => Assert.True(alias.Length <= 48)); + } + + [Fact] + public void Transport_names_round_trip_and_honour_the_server_name() + { + var catalog = new ToolCatalog([Tool("probe")], "pi"); + Assert.Equal("mcp__pi__probe", catalog.ToolToTransport["probe"]); + Assert.Equal("probe", catalog.TransportToTool["mcp__pi__probe"]); + Assert.Equal("mcp__pi__", catalog.TransportPrefix); + Assert.False(catalog.TransportToTool.ContainsKey("mcp__volcanic__probe")); + foreach (var (tool, transport) in catalog.ToolToTransport) Assert.Equal(tool, catalog.TransportToTool[transport]); + } + + [Fact] + public void Tools_are_sorted_by_name_and_serialised_as_name_description_inputSchema() + { + var catalog = new ToolCatalog([Tool("zeta", "z"), Tool("alpha", "a"), Tool("Beta", "b")]); + Assert.Equal(["Beta", "alpha", "zeta"], catalog.Tools.Select(t => t.Name).ToArray()); + + var json = JsonSerializer.Deserialize(catalog.ToJson()); + Assert.Equal(JsonValueKind.Array, json.ValueKind); + Assert.Equal(3, json.GetArrayLength()); + var first = json[0]; + Assert.Equal(["name", "description", "inputSchema"], first.EnumerateObject().Select(p => p.Name).ToArray()); + Assert.Equal("Beta", first.GetProperty("name").GetString()); + Assert.Equal("b", first.GetProperty("description").GetString()); + Assert.Equal("object", first.GetProperty("inputSchema").GetProperty("type").GetString()); + Assert.Equal("string", first.GetProperty("inputSchema").GetProperty("properties").GetProperty("value").GetProperty("type").GetString()); + } + + [Fact] + public void Empty_catalogue_has_no_transport_names() + { + var catalog = new ToolCatalog([]); + Assert.True(catalog.IsEmpty); + Assert.Empty(catalog.TransportNames); + Assert.Equal("[]", catalog.ToJson()); + } + + [Fact] + public void Limits_and_duplicates_are_rejected() + { + var tooMany = Enumerable.Range(0, ToolCatalog.MaxTools + 1).Select(i => Tool($"t{i}")).ToList(); + Assert.Equal("protocol_init", Assert.Throws(() => new ToolCatalog(tooMany)).Code); + + var duplicate = Assert.Throws(() => new ToolCatalog([Tool("same"), Tool("same")])); + Assert.Contains("Duplicate", duplicate.Message); + + var huge = Tool("big", new string('d', ToolCatalog.MaxCatalogBytes)); + Assert.Equal("protocol_init", Assert.Throws(() => new ToolCatalog([huge])).Code); + + var notObject = new ToolDescriptor("x", "d", Fixtures.Record("[]")); + Assert.Throws(() => new ToolCatalog([notObject])); + } +}