188 lines
6.6 KiB
Go
188 lines
6.6 KiB
Go
package session
|
|
|
|
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 }
|