summaryrefslogtreecommitdiff
path: root/tests/Agent.Core.Tests/InternalToolTests.cs
blob: c15745866bc039af116ed4fb25a63993bd0cdf6f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
using System.Text.Json;
using Agent.Core.Tools;

namespace Agent.Core.Tests;

public sealed class InternalToolTests
{
    [Fact]
    public void Catalog_exposes_six_strict_tool_groups()
    {
        Assert.Equal(["file_search", "file_read", "file_edit", "process_run", "task_update", "dir"], InternalToolCatalog.All.Select(x => x.Name));
        foreach (InternalToolDefinition tool in InternalToolCatalog.All) Assert.False(tool.Parameters.GetProperty("additionalProperties").GetBoolean());
    }

    [Theory]
    [InlineData("file_read", "{}")]
    [InlineData("file_read", "{\"path\":\"a\",\"mode\":\"whole\",\"unknown\":1}")]
    [InlineData("file_read", "{\"path\":\"a\",\"mode\":\"invalid\"}")]
    [InlineData("file_search", "{\"root\":\".\",\"mode\":\"text\",\"query\":\"x\",\"max_results\":1001}")]
    [InlineData("process_run", "{\"executable\":\"x\",\"args\":\"bad\",\"cwd\":\".\"}")]
    [InlineData("task_update", "{\"operation\":\"add\",\"description\":\"missing estimate\"}")]
    [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\"}")]
    [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\",\"new_path\":\"\"}")]
    public void Malformed_arguments_are_rejected(string tool, string json)
    {
        using JsonDocument document = JsonDocument.Parse(json);
        Assert.Throws<ToolValidationException>(() => InternalToolValidator.Validate(tool, document.RootElement));
    }

    [Fact]
    public async Task Policy_deny_has_no_backend_side_effect()
    {
        FakeInternalToolBackend backend = new();
        InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(), backend);
        using JsonDocument document = JsonDocument.Parse("""{"path":"x","operation":"overwrite","content":"changed"}""");

        ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "file_edit", document.RootElement, TestContext.Current.CancellationToken);

        Assert.False(result.Success);
        Assert.False(result.ExternalEffect);
        Assert.False(result.RetryAllowed);
        Assert.Empty(backend.Calls);
    }

    [Fact]
    public async Task Allowed_call_reaches_fake_backend_only_after_validation_and_policy()
    {
        FakeInternalToolBackend backend = new();
        InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(new HashSet<string>(["file_read"], StringComparer.Ordinal)), backend);
        using JsonDocument document = JsonDocument.Parse("""{"path":"x","mode":"whole"}""");

        ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "file_read", document.RootElement, TestContext.Current.CancellationToken);

        Assert.True(result.Success);
        Assert.Single(backend.Calls);
    }

    [Fact]
    public void Guard_requires_effect_and_fails_on_fifth_identical_failure()
    {
        ToolUseGuard guard = new();
        for (int attempt = 1; attempt < 5; attempt++) Assert.Equal(ToolUseGuardDecision.Retry, guard.Evaluate(true, false, false, "same").Decision);
        ToolUseGuardResult fifth = guard.Evaluate(true, false, false, "same");
        Assert.Equal(ToolUseGuardDecision.Fail, fifth.Decision);
        Assert.Equal(5, fifth.IdenticalFailureCount);
    }

    [Fact]
    public void Guard_does_not_force_tools_for_pure_analysis()
    {
        Assert.Equal(ToolUseGuardDecision.Complete, new ToolUseGuard().Evaluate(false, false, false).Decision);
    }

    [Fact]
    public void Task_update_mutates_the_real_deterministic_graph_without_external_effect_evidence()
    {
        Guid scope = Guid.NewGuid();
        Guid node = Guid.NewGuid();
        var handler = new TaskGraphToolHandler();
        handler.BeginTask(scope);
        using JsonDocument add = JsonDocument.Parse($$"""{"operation":"add","task_id":"{{node}}","description":"Build fixture","depends_on":[],"budget_class":"1K","optimistic_tokens":10,"most_likely_tokens":20,"pessimistic_tokens":30}""");

        ToolExecutionResult result = handler.Execute(scope, add.RootElement);

        Assert.True(result.Success);
        Assert.False(result.ExternalEffect);
        Assert.Contains(node.ToString(), result.Output, StringComparison.OrdinalIgnoreCase);
        handler.EndTask(scope);
        Assert.Throws<InvalidOperationException>(() => handler.Execute(scope, add.RootElement));
    }

    [Theory]
    [InlineData("dir", "{}")]
    [InlineData("dir", """{"path":"x","operation":"invalid"}""")]
    [InlineData("dir", """{"path":"x","operation":"mkdir","extra":1}""")]
    [InlineData("dir", """{"operation":"mkdir"}""")]
    public void Dir_malformed_arguments_are_rejected(string tool, string json)
    {
        using JsonDocument document = JsonDocument.Parse(json);
        Assert.Throws<ToolValidationException>(() => InternalToolValidator.Validate(tool, document.RootElement));
    }

    [Fact]
    public void Dir_catalog_definition_has_required_fields_and_strict_schema()
    {
        InternalToolDefinition definition = InternalToolCatalog.Get("dir");
        Assert.Equal("dir", definition.Name);
        Assert.False(definition.Parameters.GetProperty("additionalProperties").GetBoolean());
        Assert.Equal(["path", "operation"], definition.Parameters.GetProperty("required").EnumerateArray().Select(r => r.GetString()!).ToArray());
        Assert.Equal(["mkdir", "rmdir", "list"], definition.Parameters.GetProperty("properties").GetProperty("operation").GetProperty("enum").EnumerateArray().Select(v => v.GetString()!).ToArray());
    }

    [Fact]
    public async Task Dir_dispatched_to_fake_backend_has_external_effect()
    {
        FakeInternalToolBackend backend = new();
        InternalToolDispatcher dispatcher = new(new DenyByDefaultPolicy(new HashSet<string>(["dir"], StringComparer.Ordinal)), backend);
        using JsonDocument document = JsonDocument.Parse("""{"path":"C:\\tmp\\sub","operation":"mkdir"}""");

        ToolExecutionResult result = await dispatcher.DispatchAsync(Guid.NewGuid(), "dir", document.RootElement, TestContext.Current.CancellationToken);

        Assert.True(result.Success);
        Assert.True(result.ExternalEffect);
        Assert.Single(backend.Calls);
        Assert.Equal("dir", backend.Calls[0].Name);
    }
}