2596 lines
73 KiB
Go
2596 lines
73 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"ollie/backend"
|
|
"ollie/tools"
|
|
)
|
|
|
|
// --- mock backend ---
|
|
|
|
type mockBackend struct {
|
|
mu sync.Mutex
|
|
name string
|
|
model string
|
|
ctxLen int
|
|
models []string
|
|
respond func(context.Context, []backend.Message, []backend.Tool, backend.GenerationParams) (<-chan backend.StreamEvent, error)
|
|
}
|
|
|
|
func (m *mockBackend) ChatStream(ctx context.Context, msgs []backend.Message, ts []backend.Tool, p backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
m.mu.Lock()
|
|
fn := m.respond
|
|
m.mu.Unlock()
|
|
if fn != nil {
|
|
return fn(ctx, msgs, ts, p)
|
|
}
|
|
return textStream("ok"), nil
|
|
}
|
|
func (m *mockBackend) Name() string { return m.name }
|
|
func (m *mockBackend) DefaultModel() string { return m.model }
|
|
func (m *mockBackend) Model() string { m.mu.Lock(); defer m.mu.Unlock(); return m.model }
|
|
func (m *mockBackend) SetModel(s string) { m.mu.Lock(); m.model = s; m.mu.Unlock() }
|
|
func (m *mockBackend) ContextLength(_ context.Context) int { return m.ctxLen }
|
|
func (m *mockBackend) Models(_ context.Context) []string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.models
|
|
}
|
|
|
|
// textStream returns a single-event stream carrying the given text.
|
|
func textStream(text string) <-chan backend.StreamEvent {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{
|
|
Content: text,
|
|
Done: true,
|
|
StopReason: "stop",
|
|
Usage: backend.Usage{InputTokens: 10, OutputTokens: 5},
|
|
}
|
|
close(ch)
|
|
return ch
|
|
}
|
|
|
|
// blockedStream returns a channel that delivers a response when unblock is
|
|
// closed, or drains silently if ctx is cancelled first.
|
|
func blockedStream(ctx context.Context, unblock <-chan struct{}) <-chan backend.StreamEvent {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
go func() {
|
|
defer close(ch)
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-unblock:
|
|
ch <- backend.StreamEvent{Content: "ok", Done: true, StopReason: "stop"}
|
|
}
|
|
}()
|
|
return ch
|
|
}
|
|
|
|
// --- test helpers ---
|
|
|
|
func defaultBE() *mockBackend {
|
|
return &mockBackend{name: "mock", model: "test", ctxLen: 128000}
|
|
}
|
|
|
|
// newCore builds a minimal *agent for tests, bypassing loadSystemPrompt
|
|
// by directly setting Preamble on Runtime.
|
|
func newCore(t *testing.T, be backend.Backend, hooks Hooks) *Session {
|
|
t.Helper()
|
|
t.Setenv("OLLIE", "")
|
|
if be == nil {
|
|
be = defaultBE()
|
|
}
|
|
if hooks == nil {
|
|
hooks = Hooks{}
|
|
}
|
|
env := &Runtime{
|
|
Hooks: hooks,
|
|
Preamble: "test system prompt",
|
|
}
|
|
c := New(Config{
|
|
Backend: be,
|
|
AgentName: "test",
|
|
AgentsDir: t.TempDir(),
|
|
SessionsDir: t.TempDir(),
|
|
SessionID: NewSessionID(),
|
|
CWD: t.TempDir(),
|
|
Runtime: env,
|
|
NewDispatcher: tools.NewDispatcher,
|
|
})
|
|
t.Cleanup(c.Close)
|
|
return c
|
|
}
|
|
|
|
// collectEvents runs Submit synchronously and returns all emitted events.
|
|
func collectEvents(ctx context.Context, c *Session, input string) []Event {
|
|
var mu sync.Mutex
|
|
var evs []Event
|
|
sub := c.Bus().Subscribe("event", func(ev Event) {
|
|
mu.Lock()
|
|
evs = append(evs, ev)
|
|
mu.Unlock()
|
|
})
|
|
c.Submit(ctx, input)
|
|
c.Bus().Unsubscribe(sub)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return evs
|
|
}
|
|
|
|
// byRole returns the Content of every event with the given role.
|
|
func byRole(evs []Event, role string) []string {
|
|
var out []string
|
|
for _, ev := range evs {
|
|
if ev.Role == role {
|
|
out = append(out, ev.Content)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// waitState blocks until c.State() == want, failing after 2 s.
|
|
func waitState(t *testing.T, c *Session, want string) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
for c.State() != want {
|
|
if _, ok := c.WaitChange(ctx, WatchState, c.State()); !ok {
|
|
t.Fatalf("timed out waiting for state %q (current: %q)", want, c.State())
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Submit: happy path ---
|
|
|
|
func TestSubmit_HappyPath(t *testing.T) {
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return textStream("hello back"), nil
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
evs := collectEvents(context.Background(), c, "hello")
|
|
|
|
if got := byRole(evs, "user"); len(got) == 0 || got[0] != "hello" {
|
|
t.Errorf("user event: %v", got)
|
|
}
|
|
if got := byRole(evs, "assistant"); len(got) == 0 || got[0] != "hello back" {
|
|
t.Errorf("assistant event: %v", got)
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q; want idle", got)
|
|
}
|
|
if got := c.Reply(); got != "hello back" {
|
|
t.Errorf("Reply() = %q; want %q", got, "hello back")
|
|
}
|
|
if c.IsRunning() {
|
|
t.Error("IsRunning() = true after turn; want false")
|
|
}
|
|
}
|
|
|
|
// --- State transitions ---
|
|
|
|
func TestSubmit_StateTransitions(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
if got := c.State(); got != "idle" {
|
|
t.Fatalf("initial state = %q; want idle", got)
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
|
|
waitState(t, c, "thinking")
|
|
close(unblock)
|
|
<-done
|
|
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("final state = %q; want idle", got)
|
|
}
|
|
}
|
|
|
|
func TestSubmit_ToolCallStateTransitions(t *testing.T) {
|
|
const toolName = "my_tool"
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{
|
|
ToolCalls: []backend.ToolCall{{Name: toolName, Arguments: json.RawMessage(`{}`)}},
|
|
Done: true,
|
|
StopReason: "tool_calls",
|
|
}
|
|
close(ch)
|
|
return ch, nil
|
|
|
|
}
|
|
return textStream("done"), nil
|
|
|
|
}
|
|
|
|
c := newCore(t, be, nil)
|
|
|
|
var stateAtExec string
|
|
c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
|
|
stateAtExec = c.State()
|
|
return `{}`, nil, nil
|
|
}
|
|
|
|
collectEvents(context.Background(), c, "run tool")
|
|
|
|
wantState := "calling: " + toolName
|
|
if stateAtExec != wantState {
|
|
t.Errorf("state during tool exec = %q; want %q", stateAtExec, wantState)
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("final state = %q; want idle", got)
|
|
}
|
|
}
|
|
|
|
// --- preTurn hook ---
|
|
|
|
func TestSubmit_PreTurnHookBlocks(t *testing.T) {
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
return textStream("should not be called"), nil
|
|
|
|
}
|
|
c := newCore(t, be, Hooks{HookPreTurn: []string{"exit 2"}})
|
|
|
|
evs := collectEvents(context.Background(), c, "hello")
|
|
|
|
if callCount > 0 {
|
|
t.Error("backend called despite preTurn hook blocking")
|
|
}
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "hook blocked prompt") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected 'hook blocked prompt' info event; got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestSubmit_PreTurnHookContext(t *testing.T) {
|
|
var lastUserMsg string
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, msgs []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
for _, m := range msgs {
|
|
if m.Role == "user" {
|
|
lastUserMsg = m.Content
|
|
}
|
|
}
|
|
return textStream("ok"), nil
|
|
}
|
|
c := newCore(t, be, Hooks{HookPreTurn: []string{`echo "extra context"`}})
|
|
|
|
collectEvents(context.Background(), c, "base prompt")
|
|
|
|
if !strings.Contains(lastUserMsg, "extra context") {
|
|
t.Errorf("user message %q does not contain hook-injected context", lastUserMsg)
|
|
}
|
|
}
|
|
|
|
// --- postTurn hook continuation ---
|
|
|
|
func TestSubmit_PostTurnHookContinue(t *testing.T) {
|
|
flagFile := filepath.Join(t.TempDir(), "fired")
|
|
hookScript := fmt.Sprintf(
|
|
`if [ ! -f %q ]; then touch %q; printf "auto-continue" >&2; exit 2; fi`,
|
|
flagFile, flagFile,
|
|
)
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
return textStream(fmt.Sprintf("response %d", callCount)), nil
|
|
|
|
}
|
|
c := newCore(t, be, Hooks{HookPostTurn: []string{hookScript}})
|
|
|
|
collectEvents(context.Background(), c, "first prompt")
|
|
|
|
if callCount != 2 {
|
|
t.Errorf("backend called %d times; want 2 (original + hook continuation)", callCount)
|
|
}
|
|
}
|
|
|
|
// --- FIFO drain ---
|
|
|
|
func TestSubmit_FIFODrain(t *testing.T) {
|
|
var callCount atomic.Int32
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount.Add(1)
|
|
return textStream("ok"), nil
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
c.Queue("second")
|
|
c.Queue("third")
|
|
c.Submit(context.Background(), "first")
|
|
|
|
// Wait for async drain goroutines to complete.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for callCount.Load() < 3 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
if got := callCount.Load(); got != 3 {
|
|
t.Errorf("backend called %d times; want 3 (first + second + third)", got)
|
|
}
|
|
}
|
|
|
|
// --- pendingInject as next turn ---
|
|
|
|
// TestSubmit_PendingInjectAsNextTurn verifies that a pendingInject left
|
|
// unconsumed (no tool calls) becomes the next prompt after the turn.
|
|
func TestSubmit_PendingInjectAsNextTurn(t *testing.T) {
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
return textStream("ok"), nil
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
inject := "injected follow-up"
|
|
c.r.pendingInject.Store(&inject)
|
|
|
|
collectEvents(context.Background(), c, "first")
|
|
|
|
if callCount != 2 {
|
|
t.Errorf("backend called %d times; want 2 (first turn + inject turn)", callCount)
|
|
}
|
|
}
|
|
|
|
// --- Interrupt ---
|
|
|
|
func TestInterrupt_Running(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
|
|
waitState(t, c, "thinking")
|
|
|
|
if !c.Interrupt(ErrInterrupted) {
|
|
t.Error("Interrupt() = false; want true (action was running)")
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Submit did not return after Interrupt")
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q after interrupt; want idle", got)
|
|
}
|
|
if c.IsRunning() {
|
|
t.Error("IsRunning() = true after interrupt; want false")
|
|
}
|
|
}
|
|
|
|
func TestInterrupt_Idle(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if c.Interrupt(ErrInterrupted) {
|
|
t.Error("Interrupt() = true when idle; want false")
|
|
}
|
|
}
|
|
|
|
// --- Submit while running ---
|
|
|
|
func TestSubmit_WhileRunning(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "first")
|
|
}()
|
|
|
|
waitState(t, c, "thinking")
|
|
|
|
// Concurrent Submit must not start a new turn — always goes to FIFO.
|
|
c.Submit(context.Background(), "concurrent")
|
|
|
|
if stored := c.r.pendingInject.Load(); stored != nil {
|
|
t.Errorf("concurrent Submit set pendingInject %q; want FIFO only", *stored)
|
|
}
|
|
got, inFIFO := c.PopQueue()
|
|
if !inFIFO || got != "concurrent" {
|
|
t.Errorf("concurrent Submit PopQueue = %q, %v; want %q, true", got, inFIFO, "concurrent")
|
|
}
|
|
|
|
close(unblock)
|
|
<-done
|
|
}
|
|
|
|
// --- compaction ---
|
|
|
|
func TestManualCompact(t *testing.T) {
|
|
callCount := 0
|
|
var stateAtCompact string
|
|
be := defaultBE()
|
|
c := newCore(t, be, nil)
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
stateAtCompact = c.State()
|
|
return textStream("summary or answer"), nil
|
|
|
|
}
|
|
|
|
// Seed a session with more than hotTailSize+warmIndexSize messages so compact() doesn't short-circuit.
|
|
c.r.history = newHistory("goal")
|
|
for i := range 15 {
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d with enough text to count", i)},
|
|
backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)},
|
|
)
|
|
}
|
|
before := len(c.r.history.messages)
|
|
|
|
evs := collectEvents(context.Background(), c, "/compact")
|
|
|
|
if c.r.history == nil {
|
|
t.Fatal("session nil after compact")
|
|
}
|
|
if after := len(c.r.history.messages); after >= before {
|
|
t.Errorf("messages: before=%d after=%d; want fewer after compact", before, after)
|
|
}
|
|
if callCount == 0 {
|
|
t.Error("backend not called for compaction")
|
|
}
|
|
if stateAtCompact != "compacting" {
|
|
t.Errorf("state during compact = %q; want compacting", stateAtCompact)
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("state after compact = %q; want idle", got)
|
|
}
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "compacted") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no 'compacted' info event; got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestAutoCompact(t *testing.T) {
|
|
callCount := 0
|
|
var stateAtCompact string
|
|
// ctxLen=10 → autoCompactLimit = 7 tokens; our seeded session exceeds this.
|
|
be := &mockBackend{name: "mock", model: "test", ctxLen: 10}
|
|
c := newCore(t, be, nil)
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
stateAtCompact = c.State()
|
|
return textStream("summary text for compaction"), nil
|
|
|
|
}
|
|
return textStream("answer"), nil
|
|
|
|
}
|
|
|
|
c.r.history = newHistory("goal")
|
|
for range 15 {
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{Role: "assistant", Content: "a long response that exceeds seven tokens of content"},
|
|
backend.Message{Role: "user", Content: "follow up question with enough text to push over the limit"},
|
|
)
|
|
}
|
|
|
|
collectEvents(context.Background(), c, "next prompt")
|
|
|
|
if callCount < 2 {
|
|
t.Errorf("backend called %d times; want ≥2 (compact + turn)", callCount)
|
|
}
|
|
if stateAtCompact != "compacting" {
|
|
t.Errorf("state during auto-compact = %q; want compacting", stateAtCompact)
|
|
}
|
|
}
|
|
|
|
// --- agentSpawn ---
|
|
|
|
func TestAgentSpawnFiresOnce(t *testing.T) {
|
|
logFile := filepath.Join(t.TempDir(), "spawned")
|
|
hookScript := fmt.Sprintf(`printf "spawn\n" >> %q`, logFile)
|
|
c := newCore(t, nil, Hooks{HookAgentSpawn: []string{hookScript}})
|
|
|
|
collectEvents(context.Background(), c, "first")
|
|
collectEvents(context.Background(), c, "second")
|
|
|
|
data, err := os.ReadFile(logFile)
|
|
if err != nil {
|
|
t.Fatalf("spawn log not written: %v", err)
|
|
}
|
|
lines := strings.Count(string(data), "\n")
|
|
if lines != 1 {
|
|
t.Errorf("agentSpawn hook fired %d time(s); want 1", lines)
|
|
}
|
|
}
|
|
|
|
// --- panic recovery ---
|
|
|
|
func TestSubmit_PanicRecovery(t *testing.T) {
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
panic("backend exploded")
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
var errEvents []Event
|
|
sub := c.Bus().Subscribe("event", func(ev Event) {
|
|
if ev.Role == "error" {
|
|
errEvents = append(errEvents, ev)
|
|
}
|
|
})
|
|
c.Submit(context.Background(), "hello")
|
|
c.Bus().Unsubscribe(sub)
|
|
|
|
if len(errEvents) == 0 {
|
|
t.Error("no error event after panic; expected one")
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q after panic; want idle", got)
|
|
}
|
|
if c.IsRunning() {
|
|
t.Error("IsRunning() = true after panic; want false")
|
|
}
|
|
}
|
|
|
|
// --- commands ---
|
|
|
|
func TestCommand_Help(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/help")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "Available commands") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/help: 'Available commands' not found in info events: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Clear(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "first turn")
|
|
if c.r.history == nil {
|
|
t.Fatal("session nil after first turn")
|
|
}
|
|
oldID := c.id
|
|
collectEvents(context.Background(), c, "/clear")
|
|
if c.r.history != nil {
|
|
t.Error("session not nil after /clear")
|
|
}
|
|
if c.id != oldID {
|
|
t.Errorf("sessionID changed after /clear: %q → %q; want unchanged", oldID, c.id)
|
|
}
|
|
}
|
|
|
|
// --- CWD ---
|
|
|
|
func TestSetCWD(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
dir := t.TempDir()
|
|
if err := c.SetCWD(dir); err != nil {
|
|
t.Fatalf("SetCWD(%q): %v", dir, err)
|
|
}
|
|
if got := c.CWD(); got != dir {
|
|
t.Errorf("CWD() = %q; want %q", got, dir)
|
|
}
|
|
}
|
|
|
|
func TestSetCWD_NonExistent(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if err := c.SetCWD("/nonexistent/path/xyz/abc"); err == nil {
|
|
t.Error("SetCWD with nonexistent path returned nil; want error")
|
|
}
|
|
}
|
|
|
|
// --- Queue / PopQueue ---
|
|
|
|
func TestQueuePopQueue(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
c.Queue("a")
|
|
c.Queue("b")
|
|
|
|
if got, ok := c.PopQueue(); !ok || got != "a" {
|
|
t.Errorf("PopQueue() = %q, %v; want %q, true", got, ok, "a")
|
|
}
|
|
if got, ok := c.PopQueue(); !ok || got != "b" {
|
|
t.Errorf("PopQueue() = %q, %v; want %q, true", got, ok, "b")
|
|
}
|
|
if got, ok := c.PopQueue(); ok {
|
|
t.Errorf("PopQueue on empty = %q, true; want empty, false", got)
|
|
}
|
|
}
|
|
|
|
// --- /i and /irw ---
|
|
|
|
func TestCommand_I_Empty(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/i")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/i with no args: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_I_SetsInject(t *testing.T) {
|
|
// When running, /i sets pendingInject.
|
|
be := defaultBE()
|
|
done := make(chan struct{})
|
|
be.respond = func(_ context.Context, msgs []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
<-done
|
|
return textStream("ok"), nil
|
|
}
|
|
c := newCore(t, be, nil)
|
|
submitDone := make(chan struct{})
|
|
go func() {
|
|
c.Submit(context.Background(), "hello")
|
|
close(submitDone)
|
|
}()
|
|
waitState(t, c, "thinking")
|
|
c.handleCommand(context.Background(), "/i my inject")
|
|
p := c.r.pendingInject.Load()
|
|
if p == nil || *p != "my inject" {
|
|
t.Errorf("pendingInject = %v; want %q", p, "my inject")
|
|
}
|
|
close(done)
|
|
<-submitDone
|
|
}
|
|
|
|
func TestCommand_I_SubmitsWhenIdle(t *testing.T) {
|
|
// When idle, /i submits the prompt directly.
|
|
var callCount atomic.Int32
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount.Add(1)
|
|
return textStream("ok"), nil
|
|
}
|
|
c := newCore(t, be, nil)
|
|
c.Submit(context.Background(), "/i my prompt")
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for callCount.Load() < 1 && time.Now().Before(deadline) {
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if callCount.Load() != 1 {
|
|
t.Errorf("expected 1 backend call from /i submit; got %d", callCount.Load())
|
|
}
|
|
}
|
|
|
|
func TestCommand_IRW_Empty(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/irw")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/irw with no args: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_IRW_OverwritesInject(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
existing := "old"
|
|
c.r.pendingInject.Store(&existing)
|
|
collectEvents(context.Background(), c, "/irw new inject")
|
|
p := c.r.pendingInject.Load()
|
|
if p == nil || *p != "new inject" {
|
|
t.Errorf("pendingInject after /irw = %v; want %q", p, "new inject")
|
|
}
|
|
}
|
|
|
|
// --- /compact additional paths ---
|
|
|
|
func TestCommand_Compact_WhileRunning(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
waitState(t, c, "thinking")
|
|
|
|
evs := collectEvents(context.Background(), c, "/compact")
|
|
|
|
close(unblock)
|
|
<-done
|
|
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/compact while running: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Compact_NilSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/compact")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "nothing to compact") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/compact with nil session: expected 'nothing to compact', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Compact_PreHookBlocks(t *testing.T) {
|
|
c := newCore(t, nil, Hooks{HookPreCompact: []string{"exit 2"}})
|
|
c.r.history = newHistory("goal")
|
|
for i := range 5 {
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)},
|
|
backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)},
|
|
)
|
|
}
|
|
evs := collectEvents(context.Background(), c, "/compact")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "cancelled") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/compact with blocking preHook: expected 'cancelled', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /clear while running ---
|
|
|
|
func TestCommand_Clear_WhileRunning(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
waitState(t, c, "thinking")
|
|
|
|
evs := collectEvents(context.Background(), c, "/clear")
|
|
|
|
close(unblock)
|
|
<-done
|
|
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/clear while running: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- executeTurn: backend error ---
|
|
|
|
func TestSubmit_BackendError(t *testing.T) {
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return nil, fmt.Errorf("backend unavailable")
|
|
}
|
|
c := newCore(t, be, nil)
|
|
evs := collectEvents(context.Background(), c, "hello")
|
|
found := false
|
|
for _, ev := range evs {
|
|
if ev.Role == "error" && strings.Contains(ev.Content, "backend unavailable") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("backend error: expected error event, got: %v", evs)
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q after backend error; want idle", got)
|
|
}
|
|
}
|
|
|
|
// --- executeTurn: startup messages ---
|
|
|
|
func TestSubmit_StartupMessages(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
c.r.startupMessages = []string{"startup msg 1", "startup msg 2"}
|
|
evs := collectEvents(context.Background(), c, "hello")
|
|
found := 0
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "startup msg") {
|
|
found++
|
|
}
|
|
}
|
|
if found != 2 {
|
|
t.Errorf("startup messages: found %d info events; want 2; got: %v", found, byRole(evs, "info"))
|
|
}
|
|
if c.r.startupMessages != nil {
|
|
t.Error("startupMessages not cleared after first turn")
|
|
}
|
|
}
|
|
|
|
// --- loop: stream interrupted without Done ---
|
|
|
|
func TestRun_StreamInterrupted(t *testing.T) {
|
|
old := streamDropBaseDelay
|
|
streamDropBaseDelay = 10 * time.Millisecond
|
|
defer func() { streamDropBaseDelay = old }()
|
|
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
ch := make(chan backend.StreamEvent)
|
|
close(ch) // close without sending Done=true
|
|
return ch, nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
evs := collectEvents(context.Background(), c, "hello")
|
|
found := false
|
|
for _, ev := range evs {
|
|
if ev.Role == "error" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("stream interrupted: expected error event, got: %v", evs)
|
|
}
|
|
}
|
|
|
|
// --- loop: unknown stop reason ---
|
|
|
|
func TestRun_UnknownStopReason(t *testing.T) {
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{Done: true, StopReason: "max_completion_tokens", Content: "partial"}
|
|
close(ch)
|
|
return ch, nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
evs := collectEvents(context.Background(), c, "hello")
|
|
found := false
|
|
for _, ev := range evs {
|
|
if ev.Role == "error" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("unknown stop reason: expected error event, got: %v", evs)
|
|
}
|
|
}
|
|
|
|
// --- loop: tool call with empty name ---
|
|
|
|
func TestRun_ToolEmptyName(t *testing.T) {
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, msgs []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{
|
|
ToolCalls: []backend.ToolCall{{Name: "", Arguments: json.RawMessage(`{}`)}},
|
|
Done: true,
|
|
StopReason: "tool_calls",
|
|
}
|
|
close(ch)
|
|
return ch, nil
|
|
|
|
}
|
|
return textStream("done"), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
collectEvents(context.Background(), c, "run empty tool")
|
|
if callCount != 2 {
|
|
t.Errorf("backend called %d times; want 2 (empty-tool + follow-up)", callCount)
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q after empty-tool turn; want idle", got)
|
|
}
|
|
}
|
|
|
|
// --- loop: no tool executor configured ---
|
|
|
|
func TestRun_NoExec(t *testing.T) {
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{
|
|
ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{}`)}},
|
|
Done: true,
|
|
StopReason: "tool_calls",
|
|
}
|
|
close(ch)
|
|
return ch, nil
|
|
|
|
}
|
|
return textStream("done"), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
c.r.runtime.Exec = nil // no executor
|
|
collectEvents(context.Background(), c, "run tool")
|
|
if callCount != 2 {
|
|
t.Errorf("backend called %d times; want 2", callCount)
|
|
}
|
|
}
|
|
|
|
// --- hooks: non-zero non-two exit code ---
|
|
|
|
func TestHook_NonZeroExitCode(t *testing.T) {
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
return textStream("ok"), nil
|
|
}
|
|
// exit 1 is a non-blocking warning: turn should still proceed.
|
|
c := newCore(t, be, Hooks{HookPreTurn: []string{"exit 1"}})
|
|
collectEvents(context.Background(), c, "hello")
|
|
if callCount == 0 {
|
|
t.Error("backend not called after exit-1 preTurn hook; want call (non-blocking)")
|
|
}
|
|
}
|
|
|
|
// --- hooks: context cancelled mid-hook ---
|
|
|
|
func TestHook_ContextCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
// Sleep-10 hook is killed when ctx times out; must not deadlock.
|
|
c := newCore(t, nil, Hooks{HookPreTurn: []string{"sleep 10"}})
|
|
collectEvents(ctx, c, "hello") // returns when ctx expires
|
|
}
|
|
|
|
// --- /backend ---
|
|
|
|
func TestCommand_Backend_Show(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/backend")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "mock") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/backend: backend name not in info events: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Backend_WhileRunning(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
waitState(t, c, "thinking")
|
|
|
|
evs := collectEvents(context.Background(), c, "/backend newbackend")
|
|
|
|
close(unblock)
|
|
<-done
|
|
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/backend while running: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /model ---
|
|
|
|
func TestCommand_Model_Show(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/model")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "test") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/model: model name not in info events: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Model_Set(t *testing.T) {
|
|
be := defaultBE()
|
|
c := newCore(t, be, nil)
|
|
collectEvents(context.Background(), c, "/model gpt-4")
|
|
if got := be.Model(); got != "gpt-4" {
|
|
t.Errorf("after /model gpt-4: Model() = %q; want gpt-4", got)
|
|
}
|
|
}
|
|
|
|
func TestCommand_Model_WhileRunning(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
waitState(t, c, "thinking")
|
|
|
|
// /model is now allowed while running (needed for turnError hook recovery).
|
|
evs := collectEvents(context.Background(), c, "/model new-model")
|
|
|
|
close(unblock)
|
|
<-done
|
|
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
t.Errorf("/model while running: unexpected error: %s", s)
|
|
}
|
|
}
|
|
if got := be.Model(); got != "new-model" {
|
|
t.Errorf("model = %q; want new-model", got)
|
|
}
|
|
}
|
|
|
|
// --- /models ---
|
|
|
|
func TestCommand_Models_Empty(t *testing.T) {
|
|
c := newCore(t, nil, nil) // defaultBE has nil models
|
|
evs := collectEvents(context.Background(), c, "/models")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "no models") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/models with no models: expected 'no models available', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Models_List(t *testing.T) {
|
|
be := &mockBackend{name: "mock", model: "b", ctxLen: 128000, models: []string{"a", "b", "c"}}
|
|
c := newCore(t, be, nil)
|
|
evs := collectEvents(context.Background(), c, "/models")
|
|
infos := byRole(evs, "info")
|
|
if len(infos) < 3 {
|
|
t.Fatalf("/models: expected ≥3 info events; got: %v", infos)
|
|
}
|
|
markedCurrent := false
|
|
for _, s := range infos {
|
|
if strings.Contains(s, "* ") && strings.Contains(s, "b") {
|
|
markedCurrent = true
|
|
}
|
|
}
|
|
if !markedCurrent {
|
|
t.Errorf("/models: current model 'b' not marked with '* '; got: %v", infos)
|
|
}
|
|
}
|
|
|
|
// --- /agent ---
|
|
|
|
func TestCommand_Agent_Show(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/agent")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "test") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/agent: agent name not in info events: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Agent_WhileRunning(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
waitState(t, c, "thinking")
|
|
|
|
evs := collectEvents(context.Background(), c, "/agent other")
|
|
|
|
close(unblock)
|
|
<-done
|
|
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/agent while running: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Agent_NotFound(t *testing.T) {
|
|
c := newCore(t, nil, nil) // agentsDir is a fresh temp dir
|
|
evs := collectEvents(context.Background(), c, "/agent nonexistent")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/agent nonexistent: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /agents ---
|
|
|
|
func TestCommand_Agents_Empty(t *testing.T) {
|
|
c := newCore(t, nil, nil) // agentsDir is a fresh temp dir
|
|
t.Setenv("OLLIE_AGENTS_PATH", c.r.agentsDir)
|
|
evs := collectEvents(context.Background(), c, "/agents")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "no agents found") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/agents with empty dir: expected 'no agents found', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /sessions ---
|
|
|
|
func TestCommand_Sessions_Empty(t *testing.T) {
|
|
c := newCore(t, nil, nil) // sessionsDir is a fresh temp dir
|
|
evs := collectEvents(context.Background(), c, "/sessions")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "no sessions") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/sessions with empty dir: expected 'no sessions found', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Sessions_List(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "hello") // creates and saves session
|
|
evs := collectEvents(context.Background(), c, "/sessions")
|
|
markedCurrent := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "* ") {
|
|
markedCurrent = true
|
|
}
|
|
}
|
|
if !markedCurrent {
|
|
t.Errorf("/sessions: current session not marked with '* '; got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /cwd ---
|
|
|
|
func TestCommand_CWD_Show(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
cwd := c.CWD()
|
|
evs := collectEvents(context.Background(), c, "/cwd")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, cwd) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/cwd: path %q not in info events: %v", cwd, byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_CWD_Set(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
dir := t.TempDir()
|
|
evs := collectEvents(context.Background(), c, "/cwd "+dir)
|
|
if got := c.CWD(); got != dir {
|
|
t.Errorf("CWD() = %q; want %q", got, dir)
|
|
}
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, dir) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/cwd set: new path not confirmed in info events: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_CWD_SetNonExistent(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
old := c.CWD()
|
|
evs := collectEvents(context.Background(), c, "/cwd /nonexistent/xyz/abc")
|
|
if got := c.CWD(); got != old {
|
|
t.Errorf("CWD changed to %q after invalid path; want unchanged %q", got, old)
|
|
}
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/cwd nonexistent: expected error event, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /context ---
|
|
|
|
func TestCommand_Context_NoSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/context")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "no active session") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/context before any turn: expected 'no active session', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Context_WithSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "hello")
|
|
evs := collectEvents(context.Background(), c, "/context")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "tokens") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/context after turn: expected token usage line, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /usage ---
|
|
|
|
func TestCommand_Usage_NoSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/usage")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "no active session") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/usage before any turn: expected 'no active session', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_Usage_WithSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "hello")
|
|
evs := collectEvents(context.Background(), c, "/usage")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "requests") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/usage after turn: expected 'requests' in output, got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /history ---
|
|
|
|
func TestCommand_History_NoSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/history")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "no active session") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/history before any turn: expected 'no active session', got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
func TestCommand_History_WithSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "remember this")
|
|
evs := collectEvents(context.Background(), c, "/history")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "user") && strings.Contains(s, "remember this") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/history: user message not found in output: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /sp ---
|
|
|
|
func TestCommand_SP(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
evs := collectEvents(context.Background(), c, "/sp")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "test system prompt") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/sp: system prompt not in info events: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- /agents with files ---
|
|
|
|
func TestCommand_Agents_List(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
t.Setenv("OLLIE_AGENTS_PATH", c.r.agentsDir)
|
|
if err := os.WriteFile(c.r.agentsDir+"/myagent.json", []byte(`{}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
evs := collectEvents(context.Background(), c, "/agents")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "myagent") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/agents with files: 'myagent' not in output: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- hooksRan plural ---
|
|
|
|
func TestHooksRan(t *testing.T) {
|
|
if got := hooksRan(1); got != "1 hook run" {
|
|
t.Errorf("hooksRan(1) = %q; want %q", got, "1 hook run")
|
|
}
|
|
if got := hooksRan(3); got != "3 hooks run" {
|
|
t.Errorf("hooksRan(3) = %q; want %q", got, "3 hooks run")
|
|
}
|
|
}
|
|
|
|
// --- HookResult.Summary ---
|
|
|
|
func TestHookResult_Summary(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
hr HookResult
|
|
want string
|
|
}{
|
|
{"not ran", HookResult{}, ""},
|
|
{"all success", HookResult{Ran: true, Total: 2, Succeeded: 2}, "(2 of 2 hooks run)"},
|
|
{"one failure", HookResult{Ran: true, Total: 3, Succeeded: 2, Failed: 1, FailedCmds: []string{"bad.sh"}}, "(3 of 3 hooks run) (1 of 3 failed: bad.sh)"},
|
|
{"all failed", HookResult{Ran: true, Total: 2, Succeeded: 0, Failed: 2, FailedCmds: []string{"a.sh", "b.sh"}}, "(2 of 2 hooks run) (2 of 2 failed: a.sh, b.sh)"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.hr.Summary(); got != tt.want {
|
|
t.Errorf("Summary() = %q; want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTruncateCmd(t *testing.T) {
|
|
short := "echo hello"
|
|
if got := truncateCmd(short); got != short {
|
|
t.Errorf("truncateCmd(%q) = %q; want %q", short, got, short)
|
|
}
|
|
long := "this is a very long command that exceeds forty characters total"
|
|
got := truncateCmd(long)
|
|
if len(got) != 40 || !strings.HasSuffix(got, "...") {
|
|
t.Errorf("truncateCmd(long) = %q; want 40 chars ending in ...", got)
|
|
}
|
|
}
|
|
|
|
// --- rate-limit retry ---
|
|
|
|
func TestRun_RateLimitRetry(t *testing.T) {
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
return nil, &backend.RateLimitError{RetryAfter: time.Millisecond}
|
|
}
|
|
return textStream("ok"), nil
|
|
}
|
|
c := newCore(t, be, nil)
|
|
var retryEvents []Event
|
|
sub := c.Bus().Subscribe("event", func(ev Event) {
|
|
if ev.Role == "retry" {
|
|
retryEvents = append(retryEvents, ev)
|
|
}
|
|
})
|
|
c.Submit(context.Background(), "hello")
|
|
c.Bus().Unsubscribe(sub)
|
|
if callCount != 2 {
|
|
t.Errorf("backend called %d times; want 2 (retry + success)", callCount)
|
|
}
|
|
if len(retryEvents) == 0 {
|
|
t.Error("no retry events emitted during rate-limit retry")
|
|
}
|
|
}
|
|
|
|
// --- run: tool cancelled before execution ---
|
|
|
|
func TestRun_ToolCancelledBeforeExec(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
cancel() // cancel before tool loop runs
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{
|
|
ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{}`)}},
|
|
Done: true,
|
|
StopReason: "tool_calls",
|
|
}
|
|
close(ch)
|
|
return ch, nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
c.Submit(ctx, "hello")
|
|
if callCount != 1 {
|
|
t.Errorf("backend called %d times; want 1 (no retry after cancellation)", callCount)
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q after tool cancellation; want idle", got)
|
|
}
|
|
}
|
|
|
|
// --- run: ChatStream error while ctx cancelled (recordInterruption "request") ---
|
|
|
|
func TestRun_RequestCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
cancel()
|
|
return nil, fmt.Errorf("request failed")
|
|
}
|
|
c := newCore(t, be, nil)
|
|
c.Submit(ctx, "hello")
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q after request cancellation; want idle", got)
|
|
}
|
|
}
|
|
|
|
// --- run: Exec error while ctx cancelled, with pending inject ---
|
|
|
|
func TestRun_ExecCancelledWithInject(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{
|
|
ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{}`)}},
|
|
Done: true,
|
|
StopReason: "tool_calls",
|
|
}
|
|
close(ch)
|
|
return ch, nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
inject := "user interrupt"
|
|
c.r.pendingInject.Store(&inject)
|
|
c.r.runtime.Exec = func(_ context.Context, _ string, _ json.RawMessage) (string, []backend.ContentBlock, error) {
|
|
cancel() // ctx cancelled during exec
|
|
return "", nil, fmt.Errorf("exec cancelled")
|
|
}
|
|
c.Submit(ctx, "hello")
|
|
if callCount != 1 {
|
|
t.Errorf("backend called %d times; want 1", callCount)
|
|
}
|
|
if got := c.State(); got != "idle" {
|
|
t.Errorf("State() = %q after exec cancellation; want idle", got)
|
|
}
|
|
}
|
|
|
|
// --- auto-compact with hook context injection ---
|
|
|
|
func TestAutoCompact_WithHookContext(t *testing.T) {
|
|
callCount := 0
|
|
be := &mockBackend{name: "mock", model: "test", ctxLen: 10}
|
|
c := newCore(t, be, Hooks{
|
|
HookPreCompact: []string{`echo "pre-compact context"`},
|
|
HookPostCompact: []string{`echo "post-compact context"`},
|
|
})
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
return textStream("summary text for compaction"), nil
|
|
|
|
}
|
|
return textStream("answer"), nil
|
|
|
|
}
|
|
c.r.history = newHistory("goal")
|
|
for range 15 {
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{Role: "assistant", Content: "a long response that exceeds seven tokens of content"},
|
|
backend.Message{Role: "user", Content: "follow up question with enough text to push over the limit"},
|
|
)
|
|
}
|
|
evs := collectEvents(context.Background(), c, "next prompt")
|
|
if callCount < 2 {
|
|
t.Errorf("backend called %d times; want ≥2 (compact + turn)", callCount)
|
|
}
|
|
// Verify auto-compact ran by checking for the info message
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "auto-compacting") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no 'auto-compacting' info event; got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- Core method accessors ---
|
|
|
|
func TestCore_AgentName(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if got := c.AgentName(); got != "test" {
|
|
t.Errorf("AgentName() = %q; want %q", got, "test")
|
|
}
|
|
}
|
|
|
|
func TestCore_BackendName(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if got := c.BackendName(); got != "mock" {
|
|
t.Errorf("BackendName() = %q; want %q", got, "mock")
|
|
}
|
|
}
|
|
|
|
func TestCore_ModelName(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if got := c.ModelName(); got != "test" {
|
|
t.Errorf("ModelName() = %q; want %q", got, "test")
|
|
}
|
|
}
|
|
|
|
func TestCore_SystemPrompt(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if got := c.SystemPrompt(); got != "test system prompt" {
|
|
t.Errorf("SystemPrompt() = %q; want %q", got, "test system prompt")
|
|
}
|
|
}
|
|
|
|
func TestCore_GenerationParams(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
p := c.GenerationParams()
|
|
if p.MaxTokens != 0 {
|
|
t.Errorf("GenerationParams().MaxTokens = %d; want 0 (default)", p.MaxTokens)
|
|
}
|
|
}
|
|
|
|
func TestCore_SetGenerationParams_WhileRunning(t *testing.T) {
|
|
unblock := make(chan struct{})
|
|
be := defaultBE()
|
|
be.respond = func(ctx context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return blockedStream(ctx, unblock), nil
|
|
|
|
}
|
|
c := newCore(t, be, nil)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
c.Submit(context.Background(), "hello")
|
|
}()
|
|
waitState(t, c, "thinking")
|
|
|
|
err := c.SetGenerationParams(backend.GenerationParams{})
|
|
|
|
close(unblock)
|
|
<-done
|
|
|
|
if err == nil {
|
|
t.Error("SetGenerationParams while running: want error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCore_ListModels(t *testing.T) {
|
|
be := &mockBackend{name: "mock", model: "test", ctxLen: 128000, models: []string{"a", "b"}}
|
|
c := newCore(t, be, nil)
|
|
got := c.ListModels()
|
|
if !strings.Contains(got, "a") || !strings.Contains(got, "b") {
|
|
t.Errorf("ListModels() = %q; want 'a' and 'b'", got)
|
|
}
|
|
}
|
|
|
|
func TestCore_CtxSz_NoSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if got := c.CtxSz(); got != "no active session" {
|
|
t.Errorf("CtxSz() with no session = %q; want 'no active session'", got)
|
|
}
|
|
}
|
|
|
|
func TestCore_CtxSz_WithSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "hello")
|
|
got := c.CtxSz()
|
|
if !strings.Contains(got, "/") {
|
|
t.Errorf("CtxSz() = %q; want token fraction like '10 / 128000 (0%%)'", got)
|
|
}
|
|
}
|
|
|
|
func TestCore_Usage_NoSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
if got := c.Usage(); got != "no active session" {
|
|
t.Errorf("Usage() with no session = %q; want 'no active session'", got)
|
|
}
|
|
}
|
|
|
|
func TestCore_Usage_WithSession(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "hello")
|
|
got := c.Usage()
|
|
if !strings.Contains(got, "requests") {
|
|
t.Errorf("Usage() = %q; want string containing 'requests'", got)
|
|
}
|
|
}
|
|
|
|
// --- SetSessionID ---
|
|
|
|
func TestSetSessionID_Rename(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
collectEvents(context.Background(), c, "hello") // saves session file
|
|
oldID := c.id
|
|
newID := NewSessionID()
|
|
if err := c.SetSessionID(newID); err != nil {
|
|
t.Fatalf("SetSessionID: %v", err)
|
|
}
|
|
if c.id != newID {
|
|
t.Errorf("sessionID = %q; want %q", c.id, newID)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(c.sessionsDir, "active", oldID+".json")); !os.IsNotExist(err) {
|
|
t.Errorf("old active session file still exists after rename; err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(c.sessionsDir, "active", newID+".json")); err != nil {
|
|
t.Errorf("new active session file not found after rename: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetSessionID_UpdatesPreamble(t *testing.T) {
|
|
oldID := NewSessionID()
|
|
env := &Runtime{
|
|
Preamble: "session is " + oldID + " end",
|
|
}
|
|
c := New(Config{
|
|
Backend: defaultBE(),
|
|
AgentName: "test",
|
|
AgentsDir: t.TempDir(),
|
|
SessionsDir: t.TempDir(),
|
|
SessionID: oldID,
|
|
CWD: t.TempDir(),
|
|
Runtime: env,
|
|
NewDispatcher: tools.NewDispatcher,
|
|
})
|
|
t.Cleanup(c.Close)
|
|
|
|
newID := NewSessionID()
|
|
if err := c.SetSessionID(newID); err != nil {
|
|
t.Fatalf("SetSessionID: %v", err)
|
|
}
|
|
want := "session is " + newID + " end"
|
|
if got := c.SystemPrompt(); got != want {
|
|
t.Errorf("SystemPrompt() = %q; want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSetSessionID_SameID(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
id := c.id
|
|
if err := c.SetSessionID(id); err != nil {
|
|
t.Fatalf("SetSessionID with same ID: %v", err)
|
|
}
|
|
if c.id != id {
|
|
t.Errorf("sessionID changed: got %q; want %q", c.id, id)
|
|
}
|
|
}
|
|
|
|
// --- SetGenerationParams success ---
|
|
|
|
func TestCore_SetGenerationParams_Success(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
p := backend.GenerationParams{MaxTokens: 100}
|
|
if err := c.SetGenerationParams(p); err != nil {
|
|
t.Fatalf("SetGenerationParams: %v", err)
|
|
}
|
|
if got := c.GenerationParams().MaxTokens; got != 100 {
|
|
t.Errorf("GenerationParams().MaxTokens = %d; want 100", got)
|
|
}
|
|
}
|
|
|
|
// --- autoCompactLimit with zero ctxLen ---
|
|
|
|
func TestAutoCompactLimit_DefaultWhenZero(t *testing.T) {
|
|
be := &mockBackend{name: "mock", model: "test", ctxLen: 0}
|
|
c := newCore(t, be, nil)
|
|
limit := c.r.autoCompactLimit(context.Background())
|
|
want := defaultContextLength * 3 / 4
|
|
if limit != want {
|
|
t.Errorf("autoCompactLimit with ctxLen=0 = %d; want %d", limit, want)
|
|
}
|
|
}
|
|
|
|
// --- agentSpawn hook with context output ---
|
|
|
|
func TestAgentSpawn_WithContext(t *testing.T) {
|
|
c := newCore(t, nil, Hooks{HookAgentSpawn: []string{`echo "spawn context"`}})
|
|
collectEvents(context.Background(), c, "first")
|
|
// spawn context goes into session history, not preamble
|
|
found := false
|
|
for _, m := range c.r.history.messages {
|
|
if strings.Contains(m.Content, "spawn context") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("spawn context not found in session history")
|
|
}
|
|
}
|
|
|
|
// --- retryCountdown: ctx cancelled during wait ---
|
|
|
|
func TestRun_RateLimitRetry_Cancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
callCount := 0
|
|
be := defaultBE()
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
go func() {
|
|
time.Sleep(10 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
return nil, &backend.RateLimitError{RetryAfter: 10 * time.Second}
|
|
}
|
|
c := newCore(t, be, nil)
|
|
c.Submit(ctx, "hello")
|
|
if callCount != 1 {
|
|
t.Errorf("backend called %d times after cancelled retry; want 1", callCount)
|
|
}
|
|
}
|
|
|
|
// --- compact: backend error ---
|
|
|
|
func TestManualCompact_BackendError(t *testing.T) {
|
|
callCount := 0
|
|
be := defaultBE()
|
|
c := newCore(t, be, nil)
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
callCount++
|
|
return nil, fmt.Errorf("compact backend error")
|
|
}
|
|
c.r.history = newHistory("goal")
|
|
for i := range 15 {
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)},
|
|
backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)},
|
|
)
|
|
}
|
|
evs := collectEvents(context.Background(), c, "/compact")
|
|
if callCount == 0 {
|
|
t.Fatal("backend not called for compact")
|
|
}
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "compact error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/compact backend error: expected 'compact error' event; got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- compact: empty summary from backend ---
|
|
|
|
func TestManualCompact_EmptySummary(t *testing.T) {
|
|
be := defaultBE()
|
|
c := newCore(t, be, nil)
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
ch := make(chan backend.StreamEvent, 1)
|
|
ch <- backend.StreamEvent{Done: true, StopReason: "stop", Content: " "}
|
|
close(ch)
|
|
return ch, nil
|
|
|
|
}
|
|
c.r.history = newHistory("goal")
|
|
for i := range 15 {
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)},
|
|
backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)},
|
|
)
|
|
}
|
|
evs := collectEvents(context.Background(), c, "/compact")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "compact error") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/compact empty summary: expected 'compact error'; got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- compact: session with tool-call messages (exercises flattenToolMessages) ---
|
|
|
|
func TestManualCompact_WithToolMessages(t *testing.T) {
|
|
be := defaultBE()
|
|
c := newCore(t, be, nil)
|
|
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
|
|
return textStream("summary"), nil
|
|
|
|
}
|
|
c.r.history = newHistory("goal")
|
|
// Seed enough messages to pass the compaction threshold.
|
|
for i := range 10 {
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{Role: "assistant", Content: fmt.Sprintf("response %d", i)},
|
|
backend.Message{Role: "user", Content: fmt.Sprintf("follow up %d", i)},
|
|
)
|
|
}
|
|
c.r.history.messages = append(c.r.history.messages,
|
|
backend.Message{
|
|
Role: "assistant",
|
|
Content: "calling tool",
|
|
ToolCalls: []backend.ToolCall{{Name: "my_tool", Arguments: json.RawMessage(`{"key":"val"}`)}},
|
|
},
|
|
backend.Message{Role: "tool", Content: "tool result", ToolCallID: "1"},
|
|
backend.Message{Role: "user", Content: "more stuff"},
|
|
backend.Message{Role: "assistant", Content: "done"},
|
|
backend.Message{Role: "user", Content: "follow up"},
|
|
)
|
|
evs := collectEvents(context.Background(), c, "/compact")
|
|
found := false
|
|
for _, s := range byRole(evs, "info") {
|
|
if strings.Contains(s, "compacted") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/compact with tool messages: no 'compacted' event; got: %v", byRole(evs, "info"))
|
|
}
|
|
}
|
|
|
|
// --- hook timeout ---
|
|
|
|
func TestHookTimeout_Branch(t *testing.T) {
|
|
old := hookTimeout
|
|
hookTimeout = 0 // 0s timeout fires immediately
|
|
t.Cleanup(func() { hookTimeout = old })
|
|
|
|
// A hook that sleeps longer than the timeout. The timeout branch kills the
|
|
// process and returns a warning, but does not block the turn.
|
|
hooks := Hooks{HookPreTurn: []string{"sleep 10"}}
|
|
c := newCore(t, nil, hooks)
|
|
evs := collectEvents(context.Background(), c, "hello")
|
|
|
|
// Turn must have run: an assistant event proves the hook didn't block it.
|
|
if got := byRole(evs, "assistant"); len(got) == 0 {
|
|
t.Errorf("expected assistant event after hook timeout; hook must not have blocked the turn")
|
|
}
|
|
if c.State() != "idle" {
|
|
t.Errorf("State() = %q after timeout hook; want idle", c.State())
|
|
}
|
|
// A timeout warning should have been emitted.
|
|
infos := byRole(evs, "info")
|
|
found := false
|
|
for _, s := range infos {
|
|
if strings.Contains(s, "timed out") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected 'timed out' warning in info events; got %v", infos)
|
|
}
|
|
}
|
|
|
|
// --- /backend with injected newBackend ---
|
|
|
|
func TestCommand_Backend_Switch(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
newBE := &mockBackend{name: "injected", model: "new-model"}
|
|
c.r.newBackend = func(string) (backend.Backend, error) { return newBE, nil }
|
|
|
|
evs := collectEvents(context.Background(), c, "/backend other")
|
|
infos := byRole(evs, "info")
|
|
found := false
|
|
for _, s := range infos {
|
|
if strings.Contains(s, "injected") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/backend switch: expected 'injected' in info events; got %v", infos)
|
|
}
|
|
if c.r.runtime.Backend != newBE {
|
|
t.Errorf("/backend switch: backend not updated")
|
|
}
|
|
}
|
|
|
|
func TestCommand_Backend_Error(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
c.r.newBackend = func(string) (backend.Backend, error) { return nil, fmt.Errorf("no such backend") }
|
|
|
|
evs := collectEvents(context.Background(), c, "/backend bad")
|
|
infos := byRole(evs, "info")
|
|
found := false
|
|
for _, s := range infos {
|
|
if strings.Contains(s, "no such backend") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("/backend error: expected error message; got %v", infos)
|
|
}
|
|
}
|
|
|
|
// --- extractToolResult ---
|
|
|
|
func TestExtractToolResult_Success(t *testing.T) {
|
|
raw := json.RawMessage(`{"isError":false,"content":[{"type":"text","text":"hello"}]}`)
|
|
text, _, isErr := extractToolResult(raw)
|
|
if text != "hello" {
|
|
t.Errorf("text = %q; want %q", text, "hello")
|
|
}
|
|
if isErr {
|
|
t.Error("isError should be false")
|
|
}
|
|
}
|
|
|
|
func TestExtractToolResult_IsError(t *testing.T) {
|
|
raw := json.RawMessage(`{"isError":true,"content":[{"type":"text","text":"something failed"}]}`)
|
|
text, _, isErr := extractToolResult(raw)
|
|
if text != "something failed" {
|
|
t.Errorf("text = %q; want %q", text, "something failed")
|
|
}
|
|
if !isErr {
|
|
t.Error("isError should be true")
|
|
}
|
|
}
|
|
|
|
func TestExtractToolResult_MultipleContentItems(t *testing.T) {
|
|
raw := json.RawMessage(`{"isError":false,"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`)
|
|
text, _, _ := extractToolResult(raw)
|
|
if text != "a\nb" {
|
|
t.Errorf("text = %q; want %q", text, "a\nb")
|
|
}
|
|
}
|
|
|
|
func TestExtractToolResult_NonTextItemsSkipped(t *testing.T) {
|
|
raw := json.RawMessage(`{"isError":false,"content":[{"type":"image","text":"ignored"},{"type":"text","text":"kept"}]}`)
|
|
text, _, _ := extractToolResult(raw)
|
|
if text != "kept" {
|
|
t.Errorf("text = %q; want %q", text, "kept")
|
|
}
|
|
}
|
|
|
|
func TestExtractToolResult_ImageBlock(t *testing.T) {
|
|
raw := json.RawMessage(`{"isError":false,"content":[{"type":"image","media_type":"image/png","data":"iVBOR"},{"type":"text","text":"screenshot above"}]}`)
|
|
text, blocks, isErr := extractToolResult(raw)
|
|
if text != "screenshot above" {
|
|
t.Errorf("text = %q; want %q", text, "screenshot above")
|
|
}
|
|
if isErr {
|
|
t.Error("isError should be false")
|
|
}
|
|
if len(blocks) != 1 {
|
|
t.Fatalf("blocks len = %d; want 1", len(blocks))
|
|
}
|
|
b := blocks[0]
|
|
if b.Type != "image" || b.ImageSource == nil || b.ImageSource.MediaType != "image/png" || b.ImageSource.Data != "iVBOR" {
|
|
t.Errorf("block = %+v", b)
|
|
}
|
|
}
|
|
|
|
func TestExtractToolResult_InvalidJSON(t *testing.T) {
|
|
raw := json.RawMessage(`not json`)
|
|
text, _, isErr := extractToolResult(raw)
|
|
if text != "not json" {
|
|
t.Errorf("text = %q; want raw input on parse failure", text)
|
|
}
|
|
if isErr {
|
|
t.Error("isError should be false on parse failure")
|
|
}
|
|
}
|
|
|
|
// --- toolInfosToBackend ---
|
|
|
|
func TestToolInfosToBackend(t *testing.T) {
|
|
schema := json.RawMessage(`{"type":"object"}`)
|
|
infos := []tools.ToolInfo{
|
|
{Name: "tool_a", Description: "does A.", InputSchema: schema},
|
|
{Name: "tool_b", Description: "does B.", InputSchema: schema},
|
|
}
|
|
got := toolInfosToBackend(infos)
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d; want 2", len(got))
|
|
}
|
|
if got[0].Name != "tool_a" || got[0].Description != "does A." {
|
|
t.Errorf("got[0] = %+v", got[0])
|
|
}
|
|
if string(got[1].Parameters) != string(schema) {
|
|
t.Errorf("got[1].Parameters = %s; want %s", got[1].Parameters, schema)
|
|
}
|
|
}
|
|
|
|
func TestToolInfosToBackend_Empty(t *testing.T) {
|
|
got := toolInfosToBackend(nil)
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty slice, got %v", got)
|
|
}
|
|
}
|
|
|
|
// --- RestoreSession round-trip ---
|
|
|
|
func TestRestoreSession_RoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "sess.json")
|
|
|
|
s := newHistory("first user message")
|
|
s.appendUserMessage("first user message")
|
|
s.appendUserMessage("second message")
|
|
s.messages = append(s.messages, backend.Message{Role: "assistant", Content: "reply"})
|
|
|
|
if err := s.saveTo(path, "test-id", "test-agent", "/tmp/test"); err != nil {
|
|
t.Fatalf("saveTo: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
var ps PersistedAgent
|
|
if err := json.Unmarshal(data, &ps); err != nil {
|
|
t.Fatalf("Unmarshal: %v", err)
|
|
}
|
|
if ps.ID != "test-id" || ps.Agent != "test-agent" {
|
|
t.Errorf("ps.ID=%q ps.Agent=%q", ps.ID, ps.Agent)
|
|
}
|
|
|
|
restored := RestoreHistory(&ps)
|
|
if restored.goal != "first user message" {
|
|
t.Errorf("goal = %q; want %q", restored.goal, "first user message")
|
|
}
|
|
if len(restored.messages) != len(s.messages) {
|
|
t.Errorf("messages len = %d; want %d", len(restored.messages), len(s.messages))
|
|
}
|
|
}
|
|
|
|
func TestReactionTargetsResponseAndReplaces(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
c.r.history = &History{messages: []backend.Message{
|
|
{Role: "assistant", ID: "r1", Content: "first"},
|
|
{Role: "assistant", ID: "r2", Content: "second"},
|
|
}}
|
|
if err := c.ReactTo("r1", "👍"); err != nil {
|
|
t.Fatalf("ReactTo: %v", err)
|
|
}
|
|
if len(c.r.history.Reactions) != 1 || c.r.history.Reactions[0].ResponseID != "r1" || c.r.history.PositiveReactions != 1 {
|
|
t.Fatalf("reaction state = %+v positive=%d", c.r.history.Reactions, c.r.history.PositiveReactions)
|
|
}
|
|
if err := c.ReactTo("r1", "👎"); err != nil {
|
|
t.Fatalf("replace ReactTo: %v", err)
|
|
}
|
|
if len(c.r.history.Reactions) != 1 || c.r.history.PositiveReactions != 0 || c.r.history.NegativeReactions != 1 {
|
|
t.Fatalf("replaced reaction state = %+v positive=%d negative=%d", c.r.history.Reactions, c.r.history.PositiveReactions, c.r.history.NegativeReactions)
|
|
}
|
|
ctx := c.Context()
|
|
if got := ctx[len(ctx)-1].Content; !strings.Contains(got, "response r1: negative") {
|
|
t.Fatalf("reaction context = %q", got)
|
|
}
|
|
if err := c.ReactTo("missing", "👍"); err == nil {
|
|
t.Fatal("ReactTo missing response succeeded")
|
|
}
|
|
}
|
|
|
|
func TestReactionPersistsAndRestores(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.json")
|
|
s := &History{
|
|
messages: []backend.Message{{Role: "assistant", ID: "r1", Content: "answer"}},
|
|
Reactions: []Reaction{{ID: "x1", ResponseID: "r1", Emoji: "🚀", Category: "excellent", CreatedAt: time.Now()}},
|
|
PositiveReactions: 1,
|
|
}
|
|
if err := s.saveTo(path, "id", "agent", "/tmp"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ps, err := LoadPersistedAgent(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restored := RestoreHistory(ps)
|
|
if len(restored.Reactions) != 1 || restored.Reactions[0].ResponseID != "r1" || restored.PositiveReactions != 1 {
|
|
t.Fatalf("restored = %+v positive=%d", restored.Reactions, restored.PositiveReactions)
|
|
}
|
|
}
|
|
|
|
func TestRestoreSession_GoalFromFirstUserMessage(t *testing.T) {
|
|
msgs := []backend.Message{
|
|
{Role: "assistant", Content: "preamble"},
|
|
{Role: "user", Content: "the real goal"},
|
|
{Role: "user", Content: "second user msg"},
|
|
}
|
|
s := RestoreHistory(&PersistedAgent{Messages: msgs})
|
|
if s.goal != "the real goal" {
|
|
t.Errorf("goal = %q; want %q", s.goal, "the real goal")
|
|
}
|
|
}
|
|
|
|
// --- SetEnv propagation ---
|
|
|
|
// mockEnvServer is a tools.Server that also implements tools.EnvSetter.
|
|
type mockEnvServer struct {
|
|
mu sync.Mutex
|
|
env map[string]string
|
|
}
|
|
|
|
func (m *mockEnvServer) ListTools() ([]tools.ToolInfo, error) { return nil, nil }
|
|
func (m *mockEnvServer) CallTool(_ context.Context, _ string, _ json.RawMessage) (json.RawMessage, error) {
|
|
return nil, fmt.Errorf("not implemented")
|
|
}
|
|
func (m *mockEnvServer) SetEnv(k, v string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.env == nil {
|
|
m.env = make(map[string]string)
|
|
}
|
|
m.env[k] = v
|
|
}
|
|
func (m *mockEnvServer) get(k string) string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.env[k]
|
|
}
|
|
|
|
func newCoreWithExecServer(t *testing.T, srv *mockEnvServer) *Session {
|
|
t.Helper()
|
|
t.Setenv("OLLIE", "")
|
|
d := tools.NewDispatcher()
|
|
d.AddServer("execute", srv)
|
|
env := &Runtime{
|
|
Hooks: Hooks{},
|
|
Preamble: "test system prompt",
|
|
Dispatcher: d,
|
|
}
|
|
c := New(Config{
|
|
Backend: defaultBE(),
|
|
AgentName: "test",
|
|
AgentsDir: t.TempDir(),
|
|
SessionsDir: t.TempDir(),
|
|
SessionID: NewSessionID(),
|
|
CWD: t.TempDir(),
|
|
Runtime: env,
|
|
NewDispatcher: tools.NewDispatcher,
|
|
})
|
|
t.Cleanup(c.Close)
|
|
return c
|
|
}
|
|
|
|
func TestSetEnv_PropagatestoExecuteServer(t *testing.T) {
|
|
srv := &mockEnvServer{}
|
|
c := newCoreWithExecServer(t, srv)
|
|
|
|
c.SetEnv("MY_KEY", "my_value")
|
|
|
|
if got := srv.get("MY_KEY"); got != "my_value" {
|
|
t.Errorf("execute server env MY_KEY = %q; want %q", got, "my_value")
|
|
}
|
|
}
|
|
|
|
func TestSetEnv_StoredInCore(t *testing.T) {
|
|
srv := &mockEnvServer{}
|
|
c := newCoreWithExecServer(t, srv)
|
|
|
|
c.SetEnv("FOO", "bar")
|
|
c.SetEnv("BAZ", "qux")
|
|
|
|
c.envMu.RLock()
|
|
env := c.env
|
|
c.envMu.RUnlock()
|
|
if env["FOO"] != "bar" {
|
|
t.Errorf("env[FOO] = %q; want bar", env["FOO"])
|
|
}
|
|
if env["BAZ"] != "qux" {
|
|
t.Errorf("env[BAZ] = %q; want qux", env["BAZ"])
|
|
}
|
|
}
|
|
|
|
func TestSetEnv_NilDispatcher_NoPanic(t *testing.T) {
|
|
c := newCore(t, nil, nil)
|
|
c.r.runtime.Dispatcher = nil
|
|
// Must not panic.
|
|
c.SetEnv("K", "V")
|
|
c.envMu.RLock()
|
|
env := c.env
|
|
c.envMu.RUnlock()
|
|
if env["K"] != "V" {
|
|
t.Errorf("env[K] = %q; want V", env["K"])
|
|
}
|
|
}
|
|
|
|
// --- firstSentence ---
|
|
|
|
func TestFirstSentence_Period(t *testing.T) {
|
|
if got := firstSentence("Does a thing. More detail."); got != "Does a thing." {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFirstSentence_Newline(t *testing.T) {
|
|
if got := firstSentence("Does a thing\nMore detail"); got != "Does a thing" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFirstSentence_TruncatesLong(t *testing.T) {
|
|
long := strings.Repeat("x", 100)
|
|
got := firstSentence(long)
|
|
if len(got) != 80 || !strings.HasSuffix(got, "...") {
|
|
t.Errorf("got %q (len %d)", got, len(got))
|
|
}
|
|
}
|
|
|
|
func TestFirstSentence_ShortNoSentenceEnd(t *testing.T) {
|
|
if got := firstSentence("short"); got != "short" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|
|
|
|
// --- BuildRuntime (nil config path) ---
|
|
|
|
func setupCfgDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("OLLIE_CFG_PATH", dir)
|
|
t.Setenv("OLLIE_TOOLS_PATH", filepath.Join(dir, "tools"))
|
|
return dir
|
|
}
|
|
|
|
func TestBuildRuntime_NilConfig(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := tools.NewDispatcher()
|
|
env := BuildRuntime(nil, d, t.TempDir(), nil)
|
|
|
|
if env.Preamble != "" {
|
|
t.Errorf("preamble = %q; want empty", env.Preamble)
|
|
}
|
|
if len(env.Hooks) != 0 {
|
|
t.Errorf("expected no hooks; got %v", env.Hooks)
|
|
}
|
|
if len(env.Tools) != 0 {
|
|
t.Errorf("expected no tools; got %v", env.Tools)
|
|
}
|
|
if len(env.Messages) != 0 {
|
|
t.Errorf("expected no startup messages; got %v", env.Messages)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_PromptBecomesPreamble(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := tools.NewDispatcher()
|
|
cfg := &AgentConfig{Prompt: Prompt{Value: []string{"the prompt"}}}
|
|
env := BuildRuntime(cfg, d, t.TempDir(), nil)
|
|
|
|
if env.Preamble != "the prompt" {
|
|
t.Errorf("preamble = %q; want %q", env.Preamble, "the prompt")
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_HooksAndParams(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := tools.NewDispatcher()
|
|
temp := 0.7
|
|
cfg := &AgentConfig{
|
|
Hooks: map[string]HookCmds{"preTurn": {"echo hi"}},
|
|
MaxTokens: 512,
|
|
Temperature: &temp,
|
|
}
|
|
env := BuildRuntime(cfg, d, t.TempDir(), nil)
|
|
|
|
if cmds := env.Hooks[HookPreTurn]; len(cmds) != 1 || cmds[0] != "echo hi" {
|
|
t.Errorf("Hooks[preTurn] = %v", cmds)
|
|
}
|
|
if env.GenParams.MaxTokens != 512 {
|
|
t.Errorf("MaxTokens = %d; want 512", env.GenParams.MaxTokens)
|
|
}
|
|
if env.GenParams.Temperature == nil || *env.GenParams.Temperature != 0.7 {
|
|
t.Errorf("Temperature = %v; want 0.7", env.GenParams.Temperature)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ExecUnknownTool(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := tools.NewDispatcher()
|
|
env := BuildRuntime(nil, d, t.TempDir(), nil)
|
|
|
|
_, _, err := env.Exec(context.Background(), "no_such_tool", json.RawMessage(`{}`))
|
|
if err == nil || !strings.Contains(err.Error(), "unknown tool") {
|
|
t.Errorf("expected unknown tool error; got %v", err)
|
|
}
|
|
}
|
|
|
|
// --- mock dispatcher for BuildRuntime tests ---
|
|
|
|
type mockDispatcher struct {
|
|
tools []tools.ToolInfo
|
|
listErr error
|
|
servers map[string]tools.Server
|
|
dispatch func(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error)
|
|
}
|
|
|
|
func (m *mockDispatcher) AddServer(name string, s tools.Server) {
|
|
if m.servers == nil {
|
|
m.servers = make(map[string]tools.Server)
|
|
}
|
|
m.servers[name] = s
|
|
}
|
|
func (m *mockDispatcher) GetServer(name string) (tools.Server, bool) {
|
|
s, ok := m.servers[name]
|
|
return s, ok
|
|
}
|
|
func (m *mockDispatcher) ListTools() ([]tools.ToolInfo, error) {
|
|
return m.tools, m.listErr
|
|
}
|
|
func (m *mockDispatcher) Dispatch(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
if m.dispatch != nil {
|
|
return m.dispatch(ctx, server, tool, args)
|
|
}
|
|
return nil, fmt.Errorf("not implemented")
|
|
}
|
|
|
|
func TestBuildRuntime_ListToolsError(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := &mockDispatcher{listErr: fmt.Errorf("boom")}
|
|
env := BuildRuntime(nil, d, t.TempDir(), nil)
|
|
if len(env.Messages) != 1 || !strings.Contains(env.Messages[0], "boom") {
|
|
t.Errorf("Messages = %v", env.Messages)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ToolsPopulated(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := &mockDispatcher{
|
|
tools: []tools.ToolInfo{{Server: "s1", Name: "mytool", Description: "desc", InputSchema: json.RawMessage(`{}`)}},
|
|
}
|
|
env := BuildRuntime(nil, d, t.TempDir(), nil)
|
|
if len(env.Tools) != 1 || env.Tools[0].Name != "mytool" {
|
|
t.Errorf("tools = %+v", env.Tools)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ToolsDisabled(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := &mockDispatcher{
|
|
tools: []tools.ToolInfo{{Server: "s1", Name: "mytool", Description: "desc", InputSchema: json.RawMessage(`{}`)}},
|
|
}
|
|
f := false
|
|
cfg := &AgentConfig{Tools: &f}
|
|
env := BuildRuntime(cfg, d, t.TempDir(), nil)
|
|
if len(env.Tools) != 0 {
|
|
t.Errorf("expected no tools when disabled; got %+v", env.Tools)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_PromptOnly(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := tools.NewDispatcher()
|
|
cfg := &AgentConfig{Prompt: Prompt{Value: []string{"only agent"}}}
|
|
env := BuildRuntime(cfg, d, t.TempDir(), nil)
|
|
if env.Preamble != "only agent" {
|
|
t.Errorf("preamble = %q; want %q", env.Preamble, "only agent")
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ExecPrompt(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := tools.NewDispatcher()
|
|
cfg := &AgentConfig{Prompt: Prompt{
|
|
Value: []string{"echo hello", "echo 'You are a security auditor.'", "echo world"},
|
|
IsExec: true,
|
|
}}
|
|
env := BuildRuntime(cfg, d, t.TempDir(), nil)
|
|
want := "hello\nYou are a security auditor.\nworld"
|
|
if env.Preamble != want {
|
|
t.Errorf("preamble = %q; want %q", env.Preamble, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ExecPromptFileResolution(t *testing.T) {
|
|
dir := setupCfgDir(t)
|
|
// Create a prompts directory with a test prompt file.
|
|
promptsDir := filepath.Join(dir, "prompts")
|
|
os.MkdirAll(promptsDir, 0o755)
|
|
os.WriteFile(filepath.Join(promptsDir, "test-prompt.md"), []byte("Hello ${PRIME_PLATFORM}"), 0o644)
|
|
t.Setenv("OLLIE_PROMPTS_PATH", promptsDir)
|
|
|
|
d := tools.NewDispatcher()
|
|
cfg := &AgentConfig{Prompt: Prompt{
|
|
Value: []string{"test-prompt", "echo extra"},
|
|
IsExec: true,
|
|
}}
|
|
cwd := t.TempDir()
|
|
rt := BuildRuntime(cfg, d, cwd, PromptEnv(cwd))
|
|
if !strings.Contains(rt.Preamble, "Hello linux") {
|
|
t.Errorf("expected file content with expanded vars, got %q", rt.Preamble)
|
|
}
|
|
if !strings.Contains(rt.Preamble, "extra") {
|
|
t.Errorf("expected shell fallback output, got %q", rt.Preamble)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ExecDispatchSuccess(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := &mockDispatcher{
|
|
tools: []tools.ToolInfo{{Server: "s1", Name: "mytool"}},
|
|
dispatch: func(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
return json.RawMessage(`{"content":[{"type":"text","text":"ok"}]}`), nil
|
|
},
|
|
}
|
|
env := BuildRuntime(nil, d, t.TempDir(), nil)
|
|
result, _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != "ok" {
|
|
t.Errorf("result = %q", result)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ExecDispatchError(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := &mockDispatcher{
|
|
tools: []tools.ToolInfo{{Server: "s1", Name: "mytool"}},
|
|
dispatch: func(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
return nil, fmt.Errorf("dispatch failed")
|
|
},
|
|
}
|
|
env := BuildRuntime(nil, d, t.TempDir(), nil)
|
|
_, _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
|
|
if err == nil || !strings.Contains(err.Error(), "dispatch failed") {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildRuntime_ExecToolResultIsError(t *testing.T) {
|
|
setupCfgDir(t)
|
|
d := &mockDispatcher{
|
|
tools: []tools.ToolInfo{{Server: "s1", Name: "mytool"}},
|
|
dispatch: func(ctx context.Context, server, tool string, args json.RawMessage) (json.RawMessage, error) {
|
|
return json.RawMessage(`{"isError":true,"content":[{"type":"text","text":"bad thing"}]}`), nil
|
|
},
|
|
}
|
|
env := BuildRuntime(nil, d, t.TempDir(), nil)
|
|
_, _, err := env.Exec(context.Background(), "mytool", json.RawMessage(`{}`))
|
|
if err == nil || !strings.Contains(err.Error(), "bad thing") {
|
|
t.Errorf("err = %v", err)
|
|
}
|
|
}
|