using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Mcp; using ClawdDotNet.Core.Runtimes; using ClawdDotNet.Core.Sandbox; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Runtimes; /// /// #712: A foreign agent in the engine — it goes to the runner instead of the model, and /// everything around a run (usage, chat, the refusal without a runner) stays the same. /// public sealed class ForeignRunEngineTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "foreign-engine-" + Guid.NewGuid().ToString("N")[..8]); private readonly SqliteUsageRepository _usage; private readonly EngineFixture _fixture; public ForeignRunEngineTests() { _usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_root, "state.db"))); _fixture = new EngineFixture(usage: _usage); } public void Dispose() { SqliteStorage.ReleasePooledConnections(_root); try { Directory.Delete(_root, recursive: true); } catch { /* cleanup is secondary */ } } private sealed class ScriptedSandbox(string output) : ISandboxRunner { public SandboxRequest? Request { get; private set; } public bool IsAvailable => true; public string? UnavailableReason => null; public Task RunAsync(SandboxRequest request, CancellationToken ct) { Request = request; return Task.FromResult(new SandboxResult { ExitCode = 0, StandardOutput = output }); } } private Core.Config.AgentConfig ForeignAgent(string kind = RuntimeKinds.Command) { var agent = _fixture.AddAgent("coder"); agent.AgentDir = Path.Combine(_root, "Agents", "Agent-coder"); agent.WorkspacePath = Path.Combine(agent.AgentDir, "Workspace"); Directory.CreateDirectory(agent.WorkspacePath); agent.Runtime = new AgentRuntimeConfig { Kind = kind, Command = "/usr/bin/answer" }; return agent; } private ScriptedSandbox UseSandbox(string output, ClaudeCodeSettings? claude = null) { var sandbox = new ScriptedSandbox(output); _fixture.Engine.ForeignRunner = new ForeignRunner(sandbox, [new CommandRuntime(), new ClaudeCodeRuntime(() => claude ?? new ClaudeCodeSettings())], new McpGrants(), null, null, NullLogger.Instance); return sandbox; } [Fact] public async Task A_foreign_agent_does_not_reach_the_model() { var agent = ForeignAgent(); var sandbox = UseSandbox("answer from the command"); var result = await _fixture.Engine.RunAsync(agent, "Do it.", "test-instance", CancellationToken.None); result.Status.ShouldBe(AgentRunStatus.Completed); result.FinalMessage.ShouldBe("answer from the command"); _fixture.Client.ReceivedRequests.ShouldBeEmpty(); sandbox.Request!.StandardInput!.ShouldContain("Do it."); } [Fact] public async Task The_briefing_is_the_one_a_native_run_gets() { var agent = ForeignAgent(); var sandbox = UseSandbox("ok"); await _fixture.Engine.RunAsync(agent, "Do it.", "test-instance", CancellationToken.None); // The runner removed the run's files; the briefing went to the sandbox through them. sandbox.Request!.Environment["CLAWD_BRIEFING_FILE"].ShouldBe("/run/agent/briefing.md"); } [Fact] public async Task Without_a_runner_the_agent_does_not_fall_back_to_the_native_engine() { var agent = ForeignAgent(); var result = await _fixture.Engine.RunAsync(agent, "Do it.", "test-instance", CancellationToken.None); result.Status.ShouldBe(AgentRunStatus.Failed); result.FinalMessage!.ShouldContain("not set up"); _fixture.Client.ReceivedRequests.ShouldBeEmpty(); } [Fact] public async Task A_subscription_run_is_recorded_with_its_tokens_and_marked() { var agent = ForeignAgent(RuntimeKinds.ClaudeCode); var claude = Path.Combine(_root, "claude"); File.WriteAllText(claude, ""); UseSandbox(""" {"type":"system","subtype":"init","model":"claude-sonnet-5"} {"type":"result","subtype":"success","is_error":false,"num_turns":3,"result":"done","total_cost_usd":0.9,"usage":{"input_tokens":100,"cache_read_input_tokens":900,"output_tokens":50}} """, new ClaudeCodeSettings { Executable = claude, Auth = ClaudeCodeSettings.SubscriptionAuth, OAuthToken = "oat" }); await _fixture.Engine.RunAsync(agent, "Do it.", "test-instance", CancellationToken.None); var usage = (await _usage.GetRecentAsync(5, CancellationToken.None)).Single(); usage.Runtime.ShouldBe(RuntimeKinds.ClaudeCode); usage.OnSubscription.ShouldBeTrue(); usage.CostUsd.ShouldBe(0m, "a subscription does not bill the run"); usage.CostIsKnown.ShouldBeTrue(); usage.PromptTokens.ShouldBe(1000); usage.CompletionTokens.ShouldBe(50); usage.Model.ShouldBe("claude-sonnet-5"); } [Fact] public async Task In_a_chat_each_message_is_a_run_and_both_sides_are_shown() { var agent = ForeignAgent(); UseSandbox("the answer"); await _fixture.Engine.ChatAsync(agent, "a question", "test-instance", CancellationToken.None); _fixture.Engine.GetChatHistory("coder").Select(e => (e.Role, e.Content)).ShouldBe([ ("user", "a question"), ("assistant", "the answer") ]); } }