209 lines
7.2 KiB
Go
209 lines
7.2 KiB
Go
package backend_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"ollie/cmd/olliesrv/internal/backend"
|
|
)
|
|
|
|
// TestSanitizeMessages defines what a valid conversation context means.
|
|
// Rules:
|
|
// 1. Every assistant message with ToolCalls must be followed by tool messages
|
|
// with matching ToolCallIDs (one tool message per tool call).
|
|
// 2. The conversation should not end with a user message expecting a response
|
|
// (this is actually fine for sub-agent inheritance — the sub-agent will respond).
|
|
// 3. System messages can appear anywhere but typically at the start.
|
|
// 4. Empty conversations are valid.
|
|
|
|
func TestSanitizeMessages_Empty(t *testing.T) {
|
|
msgs := []backend.Message{}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty, got %d messages", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_SimpleConversation(t *testing.T) {
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Hello"},
|
|
{Role: "assistant", Content: "Hi there!"},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 2 {
|
|
t.Errorf("expected 2 messages, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_UserOnly(t *testing.T) {
|
|
// A conversation ending with user message is valid — assistant will respond
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 1 {
|
|
t.Errorf("expected 1 message, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_ToolCallWithResults(t *testing.T) {
|
|
// Valid: assistant calls tools, followed by tool results
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Read the file"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_1", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/test"}`)},
|
|
}},
|
|
{Role: "tool", ToolCallID: "call_1", Content: "file contents here"},
|
|
{Role: "assistant", Content: "The file contains..."},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 4 {
|
|
t.Errorf("expected 4 messages, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_DanglingToolCall(t *testing.T) {
|
|
// Invalid: assistant has tool calls but no tool results follow
|
|
// Should drop the dangling assistant message
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Read the file"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_1", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/test"}`)},
|
|
}},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
// Should drop the assistant message with dangling tool call
|
|
if len(got) != 1 {
|
|
t.Errorf("expected 1 message (user only), got %d", len(got))
|
|
}
|
|
if got[0].Role != "user" {
|
|
t.Errorf("expected user message, got %s", got[0].Role)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_PartialToolResults(t *testing.T) {
|
|
// Invalid: assistant calls 2 tools but only 1 result follows
|
|
// Should drop the assistant + partial tool results
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Read two files"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_1", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/a"}`)},
|
|
{ID: "call_2", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/b"}`)},
|
|
}},
|
|
{Role: "tool", ToolCallID: "call_1", Content: "contents of a"},
|
|
// Missing call_2 result
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
// Should drop the incomplete tool call sequence
|
|
if len(got) != 1 {
|
|
t.Errorf("expected 1 message (user only), got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_MultipleToolCallSequences(t *testing.T) {
|
|
// Valid: multiple complete tool call sequences
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Do stuff"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_1", Name: "tool_a", Arguments: json.RawMessage(`{}`)},
|
|
}},
|
|
{Role: "tool", ToolCallID: "call_1", Content: "result 1"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_2", Name: "tool_b", Arguments: json.RawMessage(`{}`)},
|
|
}},
|
|
{Role: "tool", ToolCallID: "call_2", Content: "result 2"},
|
|
{Role: "assistant", Content: "Done!"},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 6 {
|
|
t.Errorf("expected 6 messages, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_DanglingToolCallInMiddle(t *testing.T) {
|
|
// First tool call sequence is complete, second is dangling
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Do stuff"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_1", Name: "tool_a", Arguments: json.RawMessage(`{}`)},
|
|
}},
|
|
{Role: "tool", ToolCallID: "call_1", Content: "result 1"},
|
|
{Role: "assistant", Content: "First part done"},
|
|
{Role: "user", Content: "Now do more"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_2", Name: "tool_b", Arguments: json.RawMessage(`{}`)},
|
|
}},
|
|
// Missing call_2 result
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
// Should keep the first complete sequence, drop the dangling one
|
|
if len(got) != 5 {
|
|
t.Errorf("expected 5 messages, got %d", len(got))
|
|
}
|
|
// Last message should be "Now do more" (user)
|
|
if got[len(got)-1].Role != "user" {
|
|
t.Errorf("expected last message to be user, got %s", got[len(got)-1].Role)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_OrphanToolResult(t *testing.T) {
|
|
// Tool result without preceding tool call — invalid, drop it
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Hello"},
|
|
{Role: "tool", ToolCallID: "orphan_call", Content: "orphan result"},
|
|
{Role: "assistant", Content: "Hi"},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
// Should drop the orphan tool result
|
|
if len(got) != 2 {
|
|
t.Errorf("expected 2 messages, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_SystemMessage(t *testing.T) {
|
|
// System messages are preserved
|
|
msgs := []backend.Message{
|
|
{Role: "system", Content: "You are helpful"},
|
|
{Role: "user", Content: "Hello"},
|
|
{Role: "assistant", Content: "Hi"},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 3 {
|
|
t.Errorf("expected 3 messages, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_AssistantWithContentAndToolCalls(t *testing.T) {
|
|
// Assistant message can have both content and tool calls
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Read the file and summarize"},
|
|
{Role: "assistant", Content: "I'll read the file now.", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_1", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/test"}`)},
|
|
}},
|
|
{Role: "tool", ToolCallID: "call_1", Content: "file contents"},
|
|
{Role: "assistant", Content: "The file contains..."},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 4 {
|
|
t.Errorf("expected 4 messages, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeMessages_ParallelToolCalls(t *testing.T) {
|
|
// Valid: assistant calls multiple tools in parallel, all results present
|
|
msgs := []backend.Message{
|
|
{Role: "user", Content: "Read both files"},
|
|
{Role: "assistant", ToolCalls: []backend.ToolCall{
|
|
{ID: "call_1", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/a"}`)},
|
|
{ID: "call_2", Name: "file_read", Arguments: json.RawMessage(`{"path":"/tmp/b"}`)},
|
|
}},
|
|
{Role: "tool", ToolCallID: "call_1", Content: "contents of a"},
|
|
{Role: "tool", ToolCallID: "call_2", Content: "contents of b"},
|
|
{Role: "assistant", Content: "Both files contain..."},
|
|
}
|
|
got := backend.SanitizeMessages(msgs)
|
|
if len(got) != 5 {
|
|
t.Errorf("expected 5 messages, got %d", len(got))
|
|
}
|
|
}
|