summaryrefslogtreecommitdiff
path: root/tests/Agent.Core.Tests/InternalToolTests.cs
blob: 4128863bdebff9959dd4894931d612d32b5c76f0 (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
128
using System.Text.Json;
using Agent.Core.Tools;

namespace Agent.Core.Tests;

public sealed class InternalToolTests
{
    [Fact]
    public void Catalog_matches_the_design_and_uses_strict_schemas()
    {
        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("task_update", "{\"operation\":\"set_state\",\"task_id\":null}")]
    [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, Guid.NewGuid());
        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);
    }
}