using System.Net; using System.Text; using System.Text.Json; using Agent.App.Configuration; using Agent.Core.Ollama; using Agent.Core.Tools; namespace Agent.Core.Tests; public sealed class OllamaClientTests { [Fact] public async Task Chat_sends_expected_request_and_returns_usage() { string? body = null; var handler = new StubHandler(async (request, _) => { body = await request.Content!.ReadAsStringAsync(); return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"done"},"prompt_eval_count":12,"eval_count":7}"""); }); OllamaClient client = Create(handler, options: new Dictionary { ["temperature"] = JsonSerializer.SerializeToElement(0.2), ["seed"] = JsonSerializer.SerializeToElement(42) }); OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], 0.7, TestContext.Current.CancellationToken); Assert.Equal("done", result.Content); Assert.Equal(12, result.InputTokens); Assert.Equal(7, result.OutputTokens); using JsonDocument document = JsonDocument.Parse(body!); JsonElement root = document.RootElement; Assert.Equal("test-model", root.GetProperty("model").GetString()); Assert.False(root.GetProperty("stream").GetBoolean()); Assert.Equal(0.7, root.GetProperty("options").GetProperty("temperature").GetDouble()); Assert.Equal(42, root.GetProperty("options").GetProperty("seed").GetInt32()); } [Fact] public async Task Thinking_is_separated_from_final_content_and_not_discarded() { var handler = new StubHandler((_, _) => Task.FromResult(Json(HttpStatusCode.OK, """{"message":{"role":"assistant","thinking":"private reasoning","content":"final answer"},"prompt_eval_count":3,"eval_count":5}"""))); var client = new OllamaClient(new HttpClient(handler), new OllamaSettings(new Uri("http://127.0.0.1:11434"), "test-model", true, "0", new Dictionary())); OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken); Assert.Equal("final answer", result.Content); Assert.Equal("private reasoning", result.Thinking); } [Fact] public async Task Thinking_level_and_assistant_thinking_history_follow_native_chat_schema() { string? body = null; var handler = new StubHandler(async (request, _) => { body = await request.Content!.ReadAsStringAsync(); return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"done"}}"""); }); var client = new OllamaClient(new HttpClient(handler), new OllamaSettings(new Uri("http://127.0.0.1:11434"), "test-model", "high", "0", new Dictionary())); await client.ChatAsync([new OllamaMessage("assistant", "", ToolCalls: [], Thinking: "prior reasoning")], null, TestContext.Current.CancellationToken); using JsonDocument document = JsonDocument.Parse(body!); Assert.Equal("high", document.RootElement.GetProperty("think").GetString()); Assert.Equal("prior reasoning", document.RootElement.GetProperty("messages")[0].GetProperty("thinking").GetString()); } [Fact] public async Task Explicit_false_override_is_serialized_as_boolean_false() { string? body = null; var handler = new StubHandler(async (request, _) => { body = await request.Content!.ReadAsStringAsync(); return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"{}"}}"""); }); var client = new OllamaClient(new HttpClient(handler), new OllamaSettings(new Uri("http://127.0.0.1:11434"), "test-model", "high", "0", new Dictionary())); using JsonDocument schema = JsonDocument.Parse("""{"type":"object"}"""); await client.ChatStructuredAsync([new OllamaMessage("user", "json")], null, schema.RootElement.Clone(), false, TestContext.Current.CancellationToken); using JsonDocument document = JsonDocument.Parse(body!); Assert.False(document.RootElement.GetProperty("think").GetBoolean()); } [Fact] public async Task Transient_failure_retries_three_times_with_configured_backoff() { int attempts = 0; var delays = new List(); var handler = new StubHandler((_, _) => { attempts++; return Task.FromResult(attempts < 4 ? Json(HttpStatusCode.ServiceUnavailable, "busy") : Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"ok"}}""")); }); var client = new OllamaClient(new HttpClient(handler), Settings(), (delay, _) => { delays.Add(delay); return Task.CompletedTask; }); OllamaResult result = await client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken); Assert.Equal("ok", result.Content); Assert.Equal(4, attempts); Assert.Equal([TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(1000)], delays); } [Fact] public async Task Permanent_failure_is_not_retried() { int attempts = 0; var handler = new StubHandler((_, _) => { attempts++; return Task.FromResult(Json(HttpStatusCode.BadRequest, "invalid")); }); OllamaClient client = Create(handler); OllamaException error = await Assert.ThrowsAsync(() => client.ChatAsync([new OllamaMessage("user", "hello")], null, TestContext.Current.CancellationToken)); Assert.False(error.IsTransient); Assert.Equal(1, attempts); } [Fact] public void Typed_options_override_extra_options() { var configuration = new OllamaConfiguration { Endpoint = "http://127.0.0.1:11434", Model = "model", Options = new OllamaGenerationOptions { Temperature = 0.4, NumCtx = 8192 }, ExtraOptions = new Dictionary { ["temperature"] = 1.9, ["num_ctx"] = 256, ["custom"] = "preserved" } }; OllamaSettings settings = OllamaSettingsFactory.Create(configuration); Assert.Equal(0.4, settings.Options["temperature"].GetDouble()); Assert.Equal(8192, settings.Options["num_ctx"].GetInt32()); Assert.Equal("preserved", settings.Options["custom"].GetString()); } [Fact] public async Task Native_tool_catalog_is_serialized_and_tool_call_is_parsed() { string? body = null; var handler = new StubHandler(async (request, _) => { body = await request.Content!.ReadAsStringAsync(); return Json(HttpStatusCode.OK, """{"message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"file_read","arguments":{"path":"x","mode":"whole"}}}]},"prompt_eval_count":2,"eval_count":1}"""); }); OllamaClient client = Create(handler); OllamaResult result = await client.ChatWithToolsAsync([new OllamaMessage("user", "read")], null, InternalToolCatalog.All, TestContext.Current.CancellationToken); using JsonDocument document = JsonDocument.Parse(body!); Assert.Equal(6, document.RootElement.GetProperty("tools").GetArrayLength()); Assert.All(document.RootElement.GetProperty("tools").EnumerateArray(), tool => Assert.False(tool.GetProperty("function").GetProperty("parameters").GetProperty("additionalProperties").GetBoolean())); OllamaToolCall call = Assert.Single(result.ToolCalls!); Assert.Equal("file_read", call.Function.Name); Assert.Equal("x", call.Function.Arguments.GetProperty("path").GetString()); } private static OllamaClient Create(HttpMessageHandler handler, IReadOnlyDictionary? options = null) => new(new HttpClient(handler), Settings(options), static (_, _) => Task.CompletedTask); private static OllamaSettings Settings(IReadOnlyDictionary? options = null) => new(new Uri("http://127.0.0.1:11434"), "test-model", false, "0", options ?? new Dictionary()); private static HttpResponseMessage Json(HttpStatusCode statusCode, string content) => new(statusCode) { Content = new StringContent(content, Encoding.UTF8, "application/json") }; private sealed class StubHandler(Func> callback) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => callback(request, cancellationToken); } }