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")
]);
}
}