This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/agent/loop_cache_test.go

188 lines
6.6 KiB
Go

package agent
import (
"context"
"encoding/json"
"sync/atomic"
"testing"
"ollie/backend"
)
// TestResultCache_HitSkipsExec verifies that a second call to a read-safe tool
// with identical arguments returns the cached result without calling Exec again.
func TestResultCache_HitSkipsExec(t *testing.T) {
var execCount int32
be := defaultBE()
be.respond = toolsStream([]backend.ToolCall{
{ID: "1", Name: "file_read", Arguments: json.RawMessage(`{"path":"a.txt"}`)},
{ID: "2", Name: "file_read", Arguments: json.RawMessage(`{"path":"a.txt"}`)},
})
c := newCore(t, be, nil)
c.r.runtime.ClassifyTool = func(string) bool { return true }
c.r.runtime.Exec = func(_ context.Context, name string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "contents of a.txt", nil, nil
}
evs := collectEvents(context.Background(), c, "dup read")
if n := atomic.LoadInt32(&execCount); n != 1 {
t.Errorf("Exec called %d times; want 1 (second should be cached)", n)
}
toolEvs := byRole(evs, "tool")
if len(toolEvs) != 2 {
t.Errorf("tool events = %d; want 2", len(toolEvs))
}
for i, ev := range toolEvs {
if ev != "contents of a.txt" {
t.Errorf("tool event[%d] = %q; want cached value", i, ev)
}
}
}
// TestResultCache_DifferentArgsMiss verifies that the same tool name with
// different arguments produces two separate Exec calls (no false cache hits).
func TestResultCache_DifferentArgsMiss(t *testing.T) {
var execCount int32
be := defaultBE()
be.respond = toolsStream([]backend.ToolCall{
{ID: "1", Name: "file_read", Arguments: json.RawMessage(`{"path":"a.txt"}`)},
{ID: "2", Name: "file_read", Arguments: json.RawMessage(`{"path":"b.txt"}`)},
})
c := newCore(t, be, nil)
c.r.runtime.ClassifyTool = func(string) bool { return true }
c.r.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "result for " + string(args), nil, nil
}
collectEvents(context.Background(), c, "different args")
if n := atomic.LoadInt32(&execCount); n != 2 {
t.Errorf("Exec called %d times; want 2 (different paths)", n)
}
}
// TestResultCache_SerialToolNotCached verifies that non-read-safe tools are
// never cached: two identical calls both hit Exec.
func TestResultCache_SerialToolNotCached(t *testing.T) {
var execCount int32
be := defaultBE()
be.respond = toolsStream([]backend.ToolCall{
{ID: "1", Name: "file_write", Arguments: json.RawMessage(`{"path":"a.txt","content":"x"}`)},
{ID: "2", Name: "file_write", Arguments: json.RawMessage(`{"path":"a.txt","content":"x"}`)},
})
c := newCore(t, be, nil)
c.r.runtime.ClassifyTool = func(name string) bool { return false } // all serial
c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "ok", nil, nil
}
collectEvents(context.Background(), c, "serial tool")
if n := atomic.LoadInt32(&execCount); n != 2 {
t.Errorf("Exec called %d times; want 2 (serial tools not cached)", n)
}
}
// multiTurnToolsStream returns a backend respond function that issues a
// different set of tool calls on each successive invocation, then returns
// a plain text response once all sets are exhausted.
func multiTurnToolsStream(turns [][]backend.ToolCall) func(context.Context, []backend.Message, []backend.Tool, backend.GenerationParams) (<-chan backend.StreamEvent, error) {
var n int32
return func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
i := int(atomic.AddInt32(&n, 1)) - 1
if i < len(turns) {
ch := make(chan backend.StreamEvent, 1)
ch <- backend.StreamEvent{ToolCalls: turns[i], Done: true, StopReason: "tool_calls"}
close(ch)
return ch, nil
}
return textStream("done"), nil
}
}
// TestResultCache_ErrorNotCached verifies that a failed Exec result is not
// stored: a second call with the same args retries Exec rather than returning
// the cached error. The two calls are issued in separate loop turns so they
// run sequentially (no batching).
func TestResultCache_ErrorNotCached(t *testing.T) {
var execCount int32
be := defaultBE()
be.respond = multiTurnToolsStream([][]backend.ToolCall{
{{ID: "1", Name: "file_read", Arguments: json.RawMessage(`{"path":"a.txt"}`)}},
{{ID: "2", Name: "file_read", Arguments: json.RawMessage(`{"path":"a.txt"}`)}},
})
c := newCore(t, be, nil)
c.r.runtime.ClassifyTool = func(string) bool { return true }
c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
n := atomic.AddInt32(&execCount, 1)
if n == 1 {
return "", nil, &mockErr{"transient failure"}
}
return "ok now", nil, nil
}
evs := collectEvents(context.Background(), c, "error then ok")
if n := atomic.LoadInt32(&execCount); n != 2 {
t.Errorf("Exec called %d times; want 2 (error must not be cached)", n)
}
toolEvs := byRole(evs, "tool")
if len(toolEvs) != 2 {
t.Fatalf("tool events = %d; want 2", len(toolEvs))
}
if toolEvs[1] != "ok now" {
t.Errorf("second result = %q; want %q", toolEvs[1], "ok now")
}
}
// TestResultCache_IdenticalParallelReads verifies that two identical read-safe
// tool calls issued in the same parallel batch (both goroutines miss the cache
// simultaneously) both complete without error and return consistent results.
func TestResultCache_IdenticalParallelReads(t *testing.T) {
var execCount int32
be := defaultBE()
be.respond = toolsStream([]backend.ToolCall{
{ID: "1", Name: "file_read", Arguments: json.RawMessage(`{"path":"a.txt"}`)},
{ID: "2", Name: "file_read", Arguments: json.RawMessage(`{"path":"a.txt"}`)},
})
c := newCore(t, be, nil)
c.r.runtime.ClassifyTool = func(string) bool { return true } // both parallel-safe → same batch
c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
atomic.AddInt32(&execCount, 1)
return "file contents", nil, nil
}
evs := collectEvents(context.Background(), c, "identical parallel")
toolEvs := byRole(evs, "tool")
if len(toolEvs) != 2 {
t.Fatalf("tool events = %d; want 2", len(toolEvs))
}
for i, ev := range toolEvs {
if ev != "file contents" {
t.Errorf("tool event[%d] = %q; want %q", i, ev, "file contents")
}
}
// Both may have executed (cache miss race) or one may have hit cache —
// either is correct. What must not happen: panic, empty result, or wrong value.
n := atomic.LoadInt32(&execCount)
if n < 1 || n > 2 {
t.Errorf("Exec called %d times; want 1 or 2", n)
}
}
type mockErr struct{ msg string }
func (e *mockErr) Error() string { return e.msg }