diff options
Diffstat (limited to 'tests/Agent.Core.Tests')
| -rw-r--r-- | tests/Agent.Core.Tests/Agent.Core.Tests.csproj | 1 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/McpHttpTests.cs | 28 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/McpStdioTests.cs | 35 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/ObservabilityTests.cs | 46 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs | 36 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/SharedFakeOllamaServer.cs | 55 | ||||
| -rw-r--r-- | tests/Agent.Core.Tests/TaskRegistryTests.cs | 13 |
7 files changed, 186 insertions, 28 deletions
diff --git a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj index 1326733..337bde9 100644 --- a/tests/Agent.Core.Tests/Agent.Core.Tests.csproj +++ b/tests/Agent.Core.Tests/Agent.Core.Tests.csproj @@ -8,6 +8,7 @@ <ItemGroup> <ProjectReference Include="..\..\src\Agent.Core\Agent.Core.csproj" /> <ProjectReference Include="..\..\src\Agent.App\Agent.App.csproj" /> + <ProjectReference Include="..\Agent.Mcp.TestHost\Agent.Mcp.TestHost.csproj" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" /> diff --git a/tests/Agent.Core.Tests/McpHttpTests.cs b/tests/Agent.Core.Tests/McpHttpTests.cs index 7e3f1cf..9edb133 100644 --- a/tests/Agent.Core.Tests/McpHttpTests.cs +++ b/tests/Agent.Core.Tests/McpHttpTests.cs @@ -6,6 +6,9 @@ using Agent.Core.Runtime; using Agent.Mcp; using ModelContextProtocol; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using System.Text.Json; namespace Agent.Core.Tests; @@ -15,9 +18,10 @@ public sealed class McpHttpTests public async Task Loopback_streamable_http_works_and_get_sse_legacy_sse_are_rejected() { int port = FreePort(); + await using var ollamaServer = new SharedFakeOllamaServer(); using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); - var settings = new OllamaSettings(new Uri("http://127.0.0.1:1"), "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>()); + var settings = new OllamaSettings(ollamaServer.Endpoint, "unused", false, "0", new Dictionary<string, System.Text.Json.JsonElement>()); Task server = McpHost.RunHttpAsync(locale, settings, new AgentRuntimeSettings("", "en", [], "sandbox"), new VisionLimits(), "127.0.0.1", port, stop.Token); using var http = new HttpClient(); Uri endpoint = new($"http://127.0.0.1:{port}/mcp"); @@ -33,10 +37,32 @@ public sealed class McpHttpTests { IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal)); + + ResultOrCreatedTask<CallToolResult> taskCall = await client.CallToolAsTaskAsync(new CallToolRequestParams + { + Name = "agent_run", + Arguments = new Dictionary<string, JsonElement> { ["request"] = JsonSerializer.SerializeToElement("task extension test") } + }, TestContext.Current.CancellationToken); + Assert.True(taskCall.IsTask); + CreateTaskResult created = taskCall.TaskCreated!; + Assert.True(Guid.TryParse(created.TaskId, out _)); + GetTaskResult? task = null; + for (int attempt = 0; attempt < 400; attempt++) + { + task = await client.GetTaskAsync(created.TaskId, TestContext.Current.CancellationToken); + if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled) break; + await Task.Delay(25, TestContext.Current.CancellationToken); + } + Assert.NotNull(task); + Assert.Equal(McpTaskStatus.Completed, task.Status); + Assert.Contains(created.TaskId, JsonSerializer.Serialize(task), StringComparison.OrdinalIgnoreCase); } using HttpResponseMessage get = await http.GetAsync(endpoint, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.MethodNotAllowed, get.StatusCode); + Assert.False(get.Headers.Contains("Mcp-Session-Id")); + using HttpResponseMessage delete = await http.DeleteAsync(endpoint, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode); using var hostileOrigin = new HttpRequestMessage(HttpMethod.Post, endpoint); hostileOrigin.Headers.Add("Origin", "https://example.invalid"); using HttpResponseMessage forbidden = await http.SendAsync(hostileOrigin, TestContext.Current.CancellationToken); diff --git a/tests/Agent.Core.Tests/McpStdioTests.cs b/tests/Agent.Core.Tests/McpStdioTests.cs index 66f4851..fcef80e 100644 --- a/tests/Agent.Core.Tests/McpStdioTests.cs +++ b/tests/Agent.Core.Tests/McpStdioTests.cs @@ -1,4 +1,4 @@ -using Agent.App.Configuration; +using Agent.Mcp.TestHost; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -15,17 +15,18 @@ public sealed class McpStdioTests { using var directory = new TestDirectory(); await using var ollama = new FakeOllamaServer(); - string configPath = CreateBundleConfiguration(directory, ollama.Endpoint); - string application = typeof(ConfigurationLoader).Assembly.Location; + string localeDirectory = CreateLocale(directory); + string application = typeof(Marker).Assembly.Location; var transport = new StdioClientTransport(new StdioClientTransportOptions { Name = "agent-mcp-test", Command = "dotnet", - Arguments = [application, "serve", "--config", configPath, "--lang", "en"], + Arguments = [application, localeDirectory, System.IO.Path.Combine(directory.Path, "mcp-tasks")], WorkingDirectory = directory.Path }); - await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + await using McpClient client = await McpClient.CreateAsync(transport, + new McpClientOptions { ProtocolVersion = "2025-11-25" }, cancellationToken: TestContext.Current.CancellationToken); IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(["agent_run", "agent_status"], tools.Select(static tool => tool.Name).Order(StringComparer.Ordinal)); foreach (McpClientTool tool in tools) @@ -71,30 +72,10 @@ public sealed class McpStdioTests return Guid.Parse(line[(key.Length + 1)..].Trim()); } - private static string CreateBundleConfiguration(TestDirectory directory, Uri endpoint) + private static string CreateLocale(TestDirectory directory) { - directory.File("prompts/user-system.md", string.Empty); directory.File("config/locales/en.json", """{"app.starting":"Starting","check.success":"OK","check.failure":"Failed {message}","check.wfp_admin_required":"Administrator required","config.invalid":"Invalid {message}","locale.invalid":"Locale {message}","mcp.task_queued":"Task {task} queued in {session}","mcp.task_not_found":"Task {task} not found","sandbox.access_denied":"Denied {path}","ollama.vision_unsupported":"No vision"}"""); - Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "skills")); - return directory.File("agent.yaml", $$""" - schema_version: 1 - application: - language: en - mcp: - transport: stdio - agent: - user_system_prompt: prompts/user-system.md - ollama: - endpoint: {{endpoint}} - model: model - localization: - directory: config/locales - fallback: en - skills: - directory: skills - logging: - directory: logs - """); + return System.IO.Path.Combine(directory.Path, "config", "locales"); } private sealed class FakeOllamaServer : IAsyncDisposable diff --git a/tests/Agent.Core.Tests/ObservabilityTests.cs b/tests/Agent.Core.Tests/ObservabilityTests.cs new file mode 100644 index 0000000..cd86bc2 --- /dev/null +++ b/tests/Agent.Core.Tests/ObservabilityTests.cs @@ -0,0 +1,46 @@ +using Agent.App.Logging; +using Agent.Core.Localization; +using Agent.Mcp; + +namespace Agent.Core.Tests; + +public sealed class ObservabilityTests +{ + [Fact] + public void Ready_is_always_visible_even_when_log_threshold_is_fatal() + { + using var directory = new TestDirectory(); + LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); + using (var application = new RollingFileLog(directory.Path, 1024 * 1024, 2, "fatal")) + using (var observer = new McpHostLogObserver(application, locale, directory.Path, false, 1024 * 1024, 2)) + observer.Ready(new McpReadyInfo("http", "http://127.0.0.1:8080/mcp", 42, "2026-07-28", true, true)); + + Assert.Contains("Agent is ready", File.ReadAllText(Path.Combine(directory.Path, "application.log")), StringComparison.Ordinal); + } + + [Fact] + public void Ready_and_http_request_are_logged_without_sensitive_payloads() + { + using var directory = new TestDirectory(); + LocaleCatalog locale = LocaleCatalog.Load(Path.GetFullPath("config/locales"), "en"); + using (var application = new RollingFileLog(directory.Path, 1024 * 1024, 2)) + { + using var observer = new McpHostLogObserver(application, locale, directory.Path, true, 1024 * 1024, 2); + observer.Ready(new McpReadyInfo("http", "http://127.0.0.1:8080/mcp", 42, "2026-07-28", true, true)); + observer.HttpRequestStarted(new McpHttpRequestStartInfo("connection", "trace", "127.0.0.1", 50123, "POST", "/mcp")); + observer.HttpRequest(new McpHttpRequestInfo(DateTimeOffset.UtcNow, "connection", "trace", "127.0.0.1", 50123, + "127.0.0.1", 8080, "POST", "/mcp", 200, 12.5, "application/json", 100, "test-client", + "2026-07-28", "tools/list", "agent_run")); + } + + string applicationText = File.ReadAllText(Path.Combine(directory.Path, "application.log")); + Assert.Contains("Agent is ready", applicationText, StringComparison.Ordinal); + Assert.Contains("remote=127.0.0.1:50123", applicationText, StringComparison.Ordinal); + string httpText = File.ReadAllText(Path.Combine(directory.Path, "http.jsonl")); + Assert.Contains("\"method\":\"POST\"", httpText, StringComparison.Ordinal); + Assert.Contains("\"remote_ip\":\"127.0.0.1\"", httpText, StringComparison.Ordinal); + Assert.DoesNotContain("Authorization", httpText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Cookie", httpText, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("prompt", httpText, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs b/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs new file mode 100644 index 0000000..2b43459 --- /dev/null +++ b/tests/Agent.Core.Tests/PersistentMcpTaskStoreTests.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using Agent.Mcp; +using ModelContextProtocol.Extensions.Tasks; + +namespace Agent.Core.Tests; + +public sealed class PersistentMcpTaskStoreTests +{ + [Fact] + public async Task Persists_terminal_task_and_marks_incomplete_task_failed_after_restart() + { + using var directory = new TestDirectory(); + var settings = new McpTaskStoreSettings(directory.Path, TimeSpan.FromHours(24), 1000); + var first = new PersistentMcpTaskStore(settings); + McpTaskInfo completed = await first.CreateTaskAsync(TestContext.Current.CancellationToken); + await first.SetCompletedAsync(completed.TaskId, JsonSerializer.SerializeToElement(new { ok = true }), TestContext.Current.CancellationToken); + McpTaskInfo working = await first.CreateTaskAsync(TestContext.Current.CancellationToken); + + var recovered = new PersistentMcpTaskStore(settings); + Assert.Equal(McpTaskStatus.Completed, (await recovered.GetTaskAsync(completed.TaskId, TestContext.Current.CancellationToken))!.Status); + McpTaskInfo failed = (await recovered.GetTaskAsync(working.TaskId, TestContext.Current.CancellationToken))!; + Assert.Equal(McpTaskStatus.Failed, failed.Status); + Assert.Equal("server_restarted", failed.StatusMessage); + } + + [Fact] + public async Task Expired_task_is_removed() + { + using var directory = new TestDirectory(); + var store = new PersistentMcpTaskStore(new McpTaskStoreSettings(directory.Path, TimeSpan.FromMilliseconds(1), 1000)); + McpTaskInfo task = await store.CreateTaskAsync(TestContext.Current.CancellationToken); + await Task.Delay(20, TestContext.Current.CancellationToken); + Assert.Null(await store.GetTaskAsync(task.TaskId, TestContext.Current.CancellationToken)); + Assert.Empty(Directory.EnumerateFiles(directory.Path, "*.json")); + } +} diff --git a/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs b/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs new file mode 100644 index 0000000..3264c77 --- /dev/null +++ b/tests/Agent.Core.Tests/SharedFakeOllamaServer.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Agent.Core.Tests; + +internal sealed class SharedFakeOllamaServer : IAsyncDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _loop; + + public SharedFakeOllamaServer() + { + _listener.Start(); + Endpoint = new Uri($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/"); + _loop = RunAsync(); + } + + public Uri Endpoint { get; } + + private async Task RunAsync() + { + try + { + while (!_stop.IsCancellationRequested) + { + using TcpClient client = await _listener.AcceptTcpClientAsync(_stop.Token).ConfigureAwait(false); + NetworkStream stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, true); + int contentLength = 0; + while (await reader.ReadLineAsync(_stop.Token).ConfigureAwait(false) is { } line && line.Length > 0) + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) contentLength = int.Parse(line[15..].Trim(), System.Globalization.CultureInfo.InvariantCulture); + if (contentLength > 0) + { + char[] body = new char[contentLength]; + await reader.ReadBlockAsync(body.AsMemory(), _stop.Token).ConfigureAwait(false); + } + byte[] payload = Encoding.UTF8.GetBytes("""{"message":{"role":"assistant","content":"done"},"prompt_eval_count":3,"eval_count":2}"""); + byte[] header = Encoding.ASCII.GetBytes($"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {payload.Length}\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(header, _stop.Token).ConfigureAwait(false); + await stream.WriteAsync(payload, _stop.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync().ConfigureAwait(false); + _listener.Stop(); + await _loop.ConfigureAwait(false); + _stop.Dispose(); + } +} diff --git a/tests/Agent.Core.Tests/TaskRegistryTests.cs b/tests/Agent.Core.Tests/TaskRegistryTests.cs index 33e16ab..28a2844 100644 --- a/tests/Agent.Core.Tests/TaskRegistryTests.cs +++ b/tests/Agent.Core.Tests/TaskRegistryTests.cs @@ -5,6 +5,19 @@ namespace Agent.Core.Tests; public sealed class TaskRegistryTests { [Fact] + public async Task Explicit_task_id_can_be_cancelled_and_observed_as_terminal() + { + var registry = new TaskRegistry(); + Guid taskId = Guid.NewGuid(); + TaskSnapshot queued = registry.Enqueue("work", null, null, requestedTaskId: taskId); + Assert.Equal(taskId, queued.TaskId); + Assert.True(registry.Cancel(taskId)); + TaskSnapshot terminal = await registry.WaitForTerminalAsync(taskId, TestContext.Current.CancellationToken); + Assert.Equal(AgentTaskState.Cancelled, terminal.State); + Assert.True(registry.GetCancellationToken(taskId).IsCancellationRequested); + } + + [Fact] public void NewTaskGetsUniqueTaskAndSessionIds() { var registry = new TaskRegistry(); |
