ollie/cmd/olliesrv/internal/agent/loop_test.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])
}
}