diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-09 21:08:20 +0900 |
| commit | b07b41cd435991cd254957af79055080c85b346e (patch) | |
| tree | 27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | |
| parent | 2fefd29e86c3b926d0cf4a362251d3f119688612 (diff) | |
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。
.NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。
Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。
Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。
Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'tests/Agent.Core.Tests/PromptAndRuntimeTests.cs')
| -rw-r--r-- | tests/Agent.Core.Tests/PromptAndRuntimeTests.cs | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs new file mode 100644 index 0000000..23ad2d2 --- /dev/null +++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs @@ -0,0 +1,220 @@ +using Agent.Core.Ollama; +using Agent.Core.Prompts; +using Agent.Core.Runtime; +using Agent.Core.Sessions; +using Agent.Core.Tasks; +using Agent.Core.Tools; +using System.Text.Json; + +namespace Agent.Core.Tests; + +public sealed class PromptAndRuntimeTests +{ + [Fact] + public void Prompt_order_is_fixed_and_request_is_last() + { + IReadOnlyList<OllamaMessage> messages = PromptComposer.Compose(new PromptContext( + "fixed", "user system", "ja", ["skill"], "capability", "graph", "summary", + [new ConversationMessage("user", "old"), new ConversationMessage("assistant", "reply")], + "new request", ["image"])); + + Assert.Equal(["fixed", "user system", "Respond in locale: ja.", "skill", "capability", "graph", "summary", "old", "reply", "new request"], messages.Select(x => x.Content)); + Assert.Equal("system", messages[0].Role); + Assert.Equal("user", messages[^1].Role); + Assert.Equal(["image"], messages[^1].Images); + } + + [Fact] + public async Task Runtime_continues_session_and_accumulates_usage() + { + var sessions = new SessionStore(); + var registry = new TaskRegistry(sessions); + var ollama = new RecordingOllamaClient(); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("custom", "ja", [], "sandbox")); + TaskSnapshot first = registry.Enqueue("first", null, 0.5); + Assert.True(registry.Transition(first.TaskId, AgentTaskState.Planning)); + Assert.True(registry.Transition(first.TaskId, AgentTaskState.Running)); + + await runtime.ExecuteAsync(first.TaskId, TestContext.Current.CancellationToken); + Assert.True(registry.TryGet(first.TaskId, null, out TaskSnapshot? completed)); + Assert.True(completed!.State == AgentTaskState.Completed, completed.Result); + Assert.Equal(3, completed.InputTokens); + Assert.Equal(2, completed.OutputTokens); + + TaskSnapshot second = registry.Enqueue("second", first.SessionId, null); + Assert.True(registry.Transition(second.TaskId, AgentTaskState.Planning)); + Assert.True(registry.Transition(second.TaskId, AgentTaskState.Running)); + await runtime.ExecuteAsync(second.TaskId, TestContext.Current.CancellationToken); + + Assert.Equal(2, ollama.Calls.Count); + Assert.Contains(ollama.Calls[1], message => message.Role == "user" && message.Content == "first"); + Assert.Contains(ollama.Calls[1], message => message.Role == "assistant" && message.Content == "answer-1"); + Assert.Equal(0.5, ollama.Temperatures[0]); + Assert.Null(ollama.Temperatures[1]); + } + + [Fact] + public void Session_store_is_process_local() + { + SessionStore first = new(); + Guid id = first.Create().SessionId; + SessionStore restarted = new(); + + Assert.False(restarted.TryGet(id, out _)); + } + + [Fact] + public async Task Non_vision_model_rejects_images_with_selected_locale_message_before_chat() + { + TaskRegistry registry = new(); + var ollama = new NonVisionOllamaClient(); + const string localized = "選択したモデルは画像入力に対応していません。"; + var runtime = new AgentRuntime(registry, ollama, + new AgentRuntimeSettings("", "ja", [], "sandbox", VisionUnsupportedMessage: localized)); + TaskSnapshot task = registry.Enqueue("画像を確認", null, null, ["base64-image"]); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); + Assert.Equal(AgentTaskState.Failed, failed!.State); + Assert.Equal(localized, failed.Result); + Assert.Equal(0, ollama.ChatCalls); + } + + [Fact] + public async Task Runtime_dispatches_native_tool_call_then_completes_only_after_effect_evidence() + { + TaskRegistry registry = new(); + FakeInternalToolBackend backend = new(); + var dispatcher = new InternalToolDispatcher( + new DenyByDefaultPolicy(InternalToolCatalog.All.Select(static item => item.Name).ToHashSet(StringComparer.Ordinal)), backend); + var ollama = new ToolCallingOllamaClient(); + var runtime = new AgentRuntime(registry, ollama, new AgentRuntimeSettings("", "ja", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("fileを修正", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? completed)); + Assert.True(completed!.State == AgentTaskState.Completed, $"{completed.Result}; backend={backend.Calls.Count}:{string.Join(',', backend.Calls.Select(x => x.Name))}; ollama={ollama.CallCount}"); + Assert.Equal("修正しました。", completed.Result); + Assert.Single(backend.Calls); + Assert.Equal("file_edit", backend.Calls[0].Name); + Assert.Equal(2, ollama.CallCount); + Assert.Equal(5, ollama.ToolCount); + } + + [Fact] + public async Task Runtime_reaches_terminal_failed_state_when_resource_cleanup_fails() + { + TaskRegistry registry = new(); + var backend = new CleanupFailingBackend(); + var dispatcher = new InternalToolDispatcher(new DenyByDefaultPolicy(), backend); + var runtime = new AgentRuntime(registry, new RecordingOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox"), dispatcher); + TaskSnapshot task = registry.Enqueue("analyze", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); + Assert.Equal(AgentTaskState.Failed, failed!.State); + Assert.Contains("cleanup failed", failed.Result, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, backend.EndCalls); + } + + [Fact] + public async Task Runtime_cannot_complete_with_an_unresolved_tool_failure() + { + TaskRegistry registry = new(); + var dispatcher = new InternalToolDispatcher( + new DenyByDefaultPolicy(new HashSet<string>(["process_run"], StringComparer.Ordinal)), new FailingProcessBackend()); + var runtime = new AgentRuntime(registry, new FailingThenFinalOllamaClient(), new AgentRuntimeSettings("", "en", [], "sandbox", MaxIterations: 2), dispatcher); + TaskSnapshot task = registry.Enqueue("run build", null, null); + registry.Transition(task.TaskId, AgentTaskState.Planning); + registry.Transition(task.TaskId, AgentTaskState.Running); + + await runtime.ExecuteAsync(task.TaskId, TestContext.Current.CancellationToken); + + Assert.True(registry.TryGet(task.TaskId, null, out TaskSnapshot? failed)); + Assert.Equal(AgentTaskState.Failed, failed!.State); + Assert.Contains("iteration limit", failed.Result, StringComparison.OrdinalIgnoreCase); + } + + private sealed class RecordingOllamaClient : IOllamaClient + { + public List<IReadOnlyList<OllamaMessage>> Calls { get; } = []; + public List<double?> Temperatures { get; } = []; + + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) + { + Calls.Add(messages); + Temperatures.Add(temperatureOverride); + return Task.FromResult(new OllamaResult($"answer-{Calls.Count}", 3, 2)); + } + } + + private sealed class ToolCallingOllamaClient : IOllamaClient + { + public int CallCount { get; private set; } + public int ToolCount { get; private set; } + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("修正しました。", 1, 1)); + public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + CallCount++; + ToolCount = tools.Count; + if (CallCount == 1) + { + using JsonDocument document = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"new"}"""); + return Task.FromResult(new OllamaResult("", 2, 1, [new OllamaToolCall(new OllamaFunctionCall("file_edit", document.RootElement.Clone()))])); + } + return Task.FromResult(new OllamaResult("修正しました。", 2, 2)); + } + } + + private sealed class NonVisionOllamaClient : IOllamaClient + { + public int ChatCalls { get; private set; } + public Task<bool?> SupportsVisionAsync(CancellationToken cancellationToken) => Task.FromResult<bool?>(false); + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) + { + ChatCalls++; + return Task.FromResult(new OllamaResult("unexpected", 0, 0)); + } + } + + private sealed class CleanupFailingBackend : IInternalToolBackend + { + public int EndCalls { get; private set; } + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => + Task.FromResult(new ToolExecutionResult(true, "unused", false)); + public Task EndTaskAsync(Guid taskId, bool success, CancellationToken cancellationToken) + { + EndCalls++; + throw new IOException("cleanup failed"); + } + } + + private sealed class FailingProcessBackend : IInternalToolBackend + { + public Task<ToolExecutionResult> ExecuteAsync(Guid taskId, string toolName, JsonElement arguments, CancellationToken cancellationToken) => + Task.FromResult(new ToolExecutionResult(false, "exit 1", true, "process:1")); + } + + private sealed class FailingThenFinalOllamaClient : IOllamaClient + { + private int _calls; + public Task<OllamaResult> ChatAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, CancellationToken cancellationToken) => + Task.FromResult(new OllamaResult("done", 1, 1)); + public Task<OllamaResult> ChatWithToolsAsync(IReadOnlyList<OllamaMessage> messages, double? temperatureOverride, IReadOnlyList<InternalToolDefinition> tools, CancellationToken cancellationToken) + { + if (_calls++ > 0) return Task.FromResult(new OllamaResult("done", 1, 1)); + using JsonDocument arguments = JsonDocument.Parse("""{"executable":"x","args":[],"cwd":"."}"""); + return Task.FromResult(new OllamaResult("", 1, 1, [new OllamaToolCall(new OllamaFunctionCall("process_run", arguments.RootElement.Clone()))])); + } + } +} |
