package agent import ( "encoding/json" "os" "testing" "ollie/backend" ) type mockBackendForCompact struct { name string model string } func (b *mockBackendForCompact) Name() string { return b.name } func (b *mockBackendForCompact) Model() string { return b.model } func TestResolveCompactionModel_ConfigWins(t *testing.T) { b := &mockBackendForCompact{name: "anthropic", model: "claude-sonnet-4-5"} got := resolveCompactionModel("my-custom-model", b) if got != "my-custom-model" { t.Errorf("got %q; want my-custom-model", got) } } func TestResolveCompactionModel_EnvOverridesDefault(t *testing.T) { t.Setenv("OLLIE_COMPACTION_MODEL", "env-model") b := &mockBackendForCompact{name: "anthropic", model: "claude-sonnet-4-5"} got := resolveCompactionModel("", b) if got != "env-model" { t.Errorf("got %q; want env-model", got) } } func TestResolveCompactionModel_BackendDefault(t *testing.T) { os.Unsetenv("OLLIE_COMPACTION_MODEL") tests := []struct { backend string want string }{ {"anthropic", "claude-3-5-haiku-latest"}, {"openai", "gpt-4o-mini"}, {"openrouter", "deepseek-v4-flash"}, {"gemini", "gemini-2.0-flash"}, {"ollama", ""}, } for _, tt := range tests { b := &mockBackendForCompact{name: tt.backend} got := resolveCompactionModel("", b) if got != tt.want { t.Errorf("backend=%s: got %q; want %q", tt.backend, got, tt.want) } } } func TestBuildCompactedHistory_OrphanedToolMessage(t *testing.T) { // Simulate a history where the hot zone boundary (total - hotTailSize) // lands on a tool message whose preceding assistant+tool_calls is outside // the hot zone. Without the fix, this produces an orphaned tool message // that causes OpenAI 400 errors. var msgs []backend.Message // Pad with enough messages so the boundary falls on the tool message. // We need total - hotTailSize to land on the tool result. // hotTailSize = 8, so we need the tool msg at index total-8. // Build: 10 user/assistant pairs (20 msgs), then assistant+tool_calls, tool result, then 7 more messages. for i := range 10 { msgs = append(msgs, backend.Message{Role: "user", Content: "q" + string(rune('0'+i))}, backend.Message{Role: "assistant", Content: "a" + string(rune('0'+i))}, ) } // assistant with tool_calls at index 20 msgs = append(msgs, backend.Message{ Role: "assistant", ToolCalls: []backend.ToolCall{{ID: "call_orphan", Name: "test_tool", Arguments: json.RawMessage(`{}`)}}, }) // tool result at index 21 — this is where hotStart would land without the fix msgs = append(msgs, backend.Message{ Role: "tool", Content: "tool output", ToolCallID: "call_orphan", }) // 7 more messages to fill the rest of the hot zone (indices 22-28) for i := range 3 { msgs = append(msgs, backend.Message{Role: "user", Content: "follow " + string(rune('0'+i))}, backend.Message{Role: "assistant", Content: "reply " + string(rune('0'+i))}, ) } msgs = append(msgs, backend.Message{Role: "user", Content: "final"}) // total = 29, hotStart = 29 - 8 = 21 (the tool message) ts := TaskState{Objective: "test"} result := buildCompactedHistory(ts, msgs) // Verify no tool message appears without a preceding assistant+tool_calls. for i, m := range result { if m.Role == "tool" { if i == 0 { t.Fatalf("result[0] is a tool message — no preceding assistant") } prev := result[i-1] if prev.Role != "assistant" && prev.Role != "tool" { t.Fatalf("result[%d] is tool but result[%d] is %q (want assistant or tool)", i, i-1, prev.Role) } if prev.Role == "assistant" && len(prev.ToolCalls) == 0 { t.Fatalf("result[%d] is tool but preceding assistant has no tool_calls", i) } } } }