143 lines
5.6 KiB
C#
143 lines
5.6 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
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<SandboxResult> 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")
|
|
]);
|
|
}
|
|
}
|