195 lines
6.3 KiB
Go
195 lines
6.3 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"ollie/cmd/olliesrv/internal/backend"
|
|
"ollie/toolsrv/protocol"
|
|
)
|
|
|
|
func TestToolConflictKeys(t *testing.T) {
|
|
rt := &Runtime{
|
|
ToolMeta: map[string]protocol.ToolInfo{
|
|
"file_read": {Name: "file_read", Scope: "read"},
|
|
"file_grep": {Name: "file_grep", Scope: "read"},
|
|
"file_edit": {Name: "file_edit", Scope: "write"},
|
|
"file_write": {Name: "file_write", Scope: "write"},
|
|
"shell": {Name: "shell", Scope: "global"},
|
|
"lsp_rename": {Name: "lsp_rename", Scope: "global"},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
call backend.ToolCall
|
|
want []string
|
|
}{
|
|
{"read scope returns nil", backend.ToolCall{Name: "file_read", Arguments: json.RawMessage(`{"path":"/a"}`)}, nil},
|
|
{"write with path", backend.ToolCall{Name: "file_edit", Arguments: json.RawMessage(`{"path":"/a","old_string":"x","new_string":"y"}`)}, []string{"/a"}},
|
|
{"global scope", backend.ToolCall{Name: "shell", Arguments: json.RawMessage(`{"cmd":"ls"}`)}, []string{conflictKeyGlobal}},
|
|
{"lsp_rename is global", backend.ToolCall{Name: "lsp_rename", Arguments: json.RawMessage(`{"path":"/a","new_name":"Foo"}`)}, []string{conflictKeyGlobal}},
|
|
{"write no path infers global", backend.ToolCall{Name: "file_write", Arguments: json.RawMessage(`{"content":"hi"}`)}, []string{conflictKeyGlobal}},
|
|
{"unknown tool infers global", backend.ToolCall{Name: "unknown", Arguments: json.RawMessage(`{}`)}, []string{conflictKeyGlobal}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := toolConflictKeys(rt, tt.call)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.want[i] {
|
|
t.Errorf("got[%d]=%q, want %q", i, got[i], tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConflictsWithSet(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
keys []string
|
|
set []string
|
|
want bool
|
|
}{
|
|
{"nil keys never conflict", nil, []string{"/a"}, false},
|
|
{"empty set never conflicts", []string{"/a"}, nil, false},
|
|
{"same path conflicts", []string{"/a"}, []string{"/a"}, true},
|
|
{"different paths no conflict", []string{"/b"}, []string{"/a"}, false},
|
|
{"global conflicts with any", []string{conflictKeyGlobal}, []string{"/a"}, true},
|
|
{"any conflicts with global", []string{"/a"}, []string{conflictKeyGlobal}, true},
|
|
{"global vs global", []string{conflictKeyGlobal}, []string{conflictKeyGlobal}, true},
|
|
{"multiple paths partial conflict", []string{"/b"}, []string{"/a", "/b"}, true},
|
|
{"multiple paths no conflict", []string{"/c"}, []string{"/a", "/b"}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := conflictsWithSet(tt.keys, tt.set)
|
|
if got != tt.want {
|
|
t.Errorf("conflictsWithSet(%v, %v) = %v, want %v", tt.keys, tt.set, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecToolCallsBatching(t *testing.T) {
|
|
// Verify the batching logic groups correctly without actually executing.
|
|
// We test by inspecting which calls would be grouped together.
|
|
rt := &Runtime{
|
|
ToolMeta: map[string]protocol.ToolInfo{
|
|
"file_read": {Name: "file_read", Scope: "read"},
|
|
"file_edit": {Name: "file_edit", Scope: "write"},
|
|
"file_write": {Name: "file_write", Scope: "write"},
|
|
"shell": {Name: "shell", Scope: "global"},
|
|
},
|
|
}
|
|
|
|
calls := []backend.ToolCall{
|
|
{ID: "1", Name: "file_edit", Arguments: json.RawMessage(`{"path":"/a"}`)},
|
|
{ID: "2", Name: "file_edit", Arguments: json.RawMessage(`{"path":"/b"}`)},
|
|
{ID: "3", Name: "file_read", Arguments: json.RawMessage(`{"path":"/a"}`)},
|
|
{ID: "4", Name: "file_edit", Arguments: json.RawMessage(`{"path":"/c"}`)},
|
|
{ID: "5", Name: "shell", Arguments: json.RawMessage(`{"cmd":"make"}`)},
|
|
{ID: "6", Name: "file_read", Arguments: json.RawMessage(`{"path":"/d"}`)},
|
|
}
|
|
|
|
// Simulate batching: track which batches form
|
|
var batches [][]string
|
|
i := 0
|
|
for i < len(calls) {
|
|
batch := []backend.ToolCall{calls[i]}
|
|
batchWrites := toolConflictKeys(rt, calls[i])
|
|
j := i + 1
|
|
for j < len(calls) {
|
|
if hasGlobal(batchWrites) {
|
|
break
|
|
}
|
|
keys := toolConflictKeys(rt, calls[j])
|
|
if conflictsWithSet(keys, batchWrites) {
|
|
break
|
|
}
|
|
batch = append(batch, calls[j])
|
|
batchWrites = mergeKeys(batchWrites, keys)
|
|
j++
|
|
}
|
|
var ids []string
|
|
for _, c := range batch {
|
|
ids = append(ids, c.ID)
|
|
}
|
|
batches = append(batches, ids)
|
|
i = j
|
|
}
|
|
|
|
// Expected batches:
|
|
// Batch 1: [1:/a, 2:/b, 3:read/a(no conflict), 4:/c] — all different write paths, reads don't conflict
|
|
// Batch 2: [5:shell(GLOBAL)] — shell is barrier, seals its own batch
|
|
// Batch 3: [6:read/d] — after barrier, continues
|
|
if len(batches) != 3 {
|
|
t.Fatalf("expected 3 batches, got %d: %v", len(batches), batches)
|
|
}
|
|
if len(batches[0]) != 4 {
|
|
t.Errorf("batch 0: expected 4 calls, got %v", batches[0])
|
|
}
|
|
if len(batches[1]) != 1 || batches[1][0] != "5" {
|
|
t.Errorf("batch 1: expected [5], got %v", batches[1])
|
|
}
|
|
if len(batches[2]) != 1 || batches[2][0] != "6" {
|
|
t.Errorf("batch 2: expected [6], got %v", batches[2])
|
|
}
|
|
}
|
|
|
|
func TestExecToolCallsSamePathSerializes(t *testing.T) {
|
|
rt := &Runtime{
|
|
ToolMeta: map[string]protocol.ToolInfo{
|
|
"file_edit": {Name: "file_edit", Scope: "write"},
|
|
},
|
|
}
|
|
|
|
calls := []backend.ToolCall{
|
|
{ID: "1", Name: "file_edit", Arguments: json.RawMessage(`{"path":"/a"}`)},
|
|
{ID: "2", Name: "file_edit", Arguments: json.RawMessage(`{"path":"/a"}`)},
|
|
{ID: "3", Name: "file_edit", Arguments: json.RawMessage(`{"path":"/b"}`)},
|
|
}
|
|
|
|
var batches [][]string
|
|
i := 0
|
|
for i < len(calls) {
|
|
batch := []backend.ToolCall{calls[i]}
|
|
batchWrites := toolConflictKeys(rt, calls[i])
|
|
j := i + 1
|
|
for j < len(calls) {
|
|
if hasGlobal(batchWrites) {
|
|
break
|
|
}
|
|
keys := toolConflictKeys(rt, calls[j])
|
|
if conflictsWithSet(keys, batchWrites) {
|
|
break
|
|
}
|
|
batch = append(batch, calls[j])
|
|
batchWrites = mergeKeys(batchWrites, keys)
|
|
j++
|
|
}
|
|
var ids []string
|
|
for _, c := range batch {
|
|
ids = append(ids, c.ID)
|
|
}
|
|
batches = append(batches, ids)
|
|
i = j
|
|
}
|
|
|
|
// Expected: [1:/a] then [2:/a, 3:/b] — second /a conflicts with first batch's /a
|
|
if len(batches) != 2 {
|
|
t.Fatalf("expected 2 batches, got %d: %v", len(batches), batches)
|
|
}
|
|
if len(batches[0]) != 1 || batches[0][0] != "1" {
|
|
t.Errorf("batch 0: expected [1], got %v", batches[0])
|
|
}
|
|
if len(batches[1]) != 2 {
|
|
t.Errorf("batch 1: expected [2,3], got %v", batches[1])
|
|
}
|
|
}
|