ollie/cmd/olliesrv/internal/agent/chat_test.go

288 lines
7.0 KiB
Go

package agent
import (
"sync"
"testing"
"ollie/format"
)
// testAgent creates a minimal Agent for chat/block testing.
func testAgent(sessionID, agentID string) *Agent {
ag := &Agent{
sessionID: sessionID,
id: agentID,
state: "idle",
}
ag.signalCh = make(chan struct{})
ag.chatSignalCh = make(chan struct{})
ag.chatCond = sync.NewCond(ag.chatMu.RLocker())
return ag
}
func TestNextBlockID_Deterministic(t *testing.T) {
ag1 := testAgent("session1", "agent1")
ag2 := testAgent("session1", "agent1")
// Same session/agent should produce same sequence
id1a := ag1.NextBlockID()
id1b := ag1.NextBlockID()
id2a := ag2.NextBlockID()
id2b := ag2.NextBlockID()
if id1a != id2a {
t.Errorf("first block IDs don't match: %s vs %s", id1a, id2a)
}
if id1b != id2b {
t.Errorf("second block IDs don't match: %s vs %s", id1b, id2b)
}
if id1a == id1b {
t.Errorf("sequential block IDs should differ: %s == %s", id1a, id1b)
}
}
func TestNextBlockID_DifferentAgents(t *testing.T) {
ag1 := testAgent("session1", "agent1")
ag2 := testAgent("session1", "agent2")
id1 := ag1.NextBlockID()
id2 := ag2.NextBlockID()
if id1 == id2 {
t.Errorf("different agents should have different block IDs: %s == %s", id1, id2)
}
}
func TestNextBlockID_DifferentSessions(t *testing.T) {
ag1 := testAgent("session1", "agent1")
ag2 := testAgent("session2", "agent1")
id1 := ag1.NextBlockID()
id2 := ag2.NextBlockID()
if id1 == id2 {
t.Errorf("different sessions should have different block IDs: %s == %s", id1, id2)
}
}
func TestNextBlockID_Length(t *testing.T) {
ag := testAgent("test-session", "test-agent")
id := ag.NextBlockID()
// Block IDs are 8 hex chars (from sha256[:8])
if len(id) != 8 {
t.Errorf("block ID length should be 8, got %d: %s", len(id), id)
}
// Should be valid hex
for _, c := range id {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("block ID contains non-hex char: %s", id)
break
}
}
}
func TestAppendBlock_JSONL(t *testing.T) {
ag := testAgent("session1", "agent1")
ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
ag.AppendBlock(format.Block{Role: "tool", ID: "11223344", Content: "output"})
ag.ChatMu().RLock()
raw := ag.RawLog()
ag.ChatMu().RUnlock()
// Should contain valid JSONL
if len(raw) == 0 {
t.Fatal("raw log should not be empty")
}
// Parse and verify
lines := 0
for _, line := range splitLines(raw) {
if len(line) == 0 {
continue
}
lines++
b, err := format.UnmarshalBlock(line)
if err != nil {
t.Errorf("failed to parse JSONL line: %v", err)
}
if b.ID == "" {
t.Error("block should have ID")
}
}
if lines != 3 {
t.Errorf("expected 3 blocks, got %d", lines)
}
}
func TestBlockByID(t *testing.T) {
ag := testAgent("session1", "agent1")
ag.AppendBlock(format.Block{Role: "user", ID: "abc12345", Content: "Hello"})
ag.AppendBlock(format.Block{Role: "assistant", ID: "def67890", Content: "Hi there"})
// Find existing block
block, found := ag.BlockByID("def67890")
if !found {
t.Fatal("block should be found")
}
if block.Role != "assistant" || block.Content != "Hi there" {
t.Errorf("wrong block content: %+v", block)
}
// Not found
_, found = ag.BlockByID("notfound")
if found {
t.Error("nonexistent block should not be found")
}
// Empty ID
_, found = ag.BlockByID("")
if found {
t.Error("empty block ID should not be found")
}
}
func TestBlockByID_SkipsPartial(t *testing.T) {
ag := testAgent("session1", "agent1")
// Add partial block first
ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "H", Partial: true})
// Then final
ag.AppendBlock(format.Block{Role: "assistant", ID: "abc12345", Content: "Hello"})
block, found := ag.BlockByID("abc12345")
if !found {
t.Fatal("block should be found")
}
if block.Content != "Hello" {
t.Errorf("should find final block, not partial: %+v", block)
}
}
func TestAppendBlock_SignalsBroadcast(t *testing.T) {
ag := testAgent("session1", "agent1")
signalCh := ag.ChatSignal()
// Append block
ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hi"})
// Signal channel should be closed
select {
case <-signalCh:
// Good - channel closed
default:
t.Error("chat signal should have fired")
}
// New signal channel should be open
newSignalCh := ag.ChatSignal()
select {
case <-newSignalCh:
t.Error("new signal channel should not be closed yet")
default:
// Good - channel open
}
}
func TestRawLogRead_Streaming(t *testing.T) {
ag := testAgent("session1", "agent1")
// Initial read with no base - should return empty and current offset
data, base, err := ag.RawLogRead("")
if err != nil {
t.Fatalf("RawLogRead error: %v", err)
}
if len(data) != 0 {
t.Errorf("initial read should be empty, got: %s", data)
}
// Append a block
ag.AppendBlock(format.Block{Role: "user", ID: "test", Content: "hello"})
// Read with previous base - should get new data
data, newBase, err := ag.RawLogRead(base)
if err != nil {
t.Fatalf("RawLogRead error: %v", err)
}
if len(data) == 0 {
t.Error("should have new data")
}
// Verify it's valid JSON
b, err := format.UnmarshalBlock(data[:len(data)-1]) // strip trailing newline
if err != nil {
t.Errorf("data should be valid JSONL: %v", err)
}
if b.Content != "hello" {
t.Errorf("wrong content: %s", b.Content)
}
// Read again with new base - should be empty
data, _, err = ag.RawLogRead(newBase)
if err != nil {
t.Fatalf("RawLogRead error: %v", err)
}
if len(data) != 0 {
t.Errorf("no new data, should be empty, got: %s", data)
}
}
func TestTextLog_RendersHumanReadable(t *testing.T) {
ag := testAgent("session1", "agent1")
ag.AppendBlock(format.Block{Role: "user", ID: "a", Content: "Hello"})
ag.AppendBlock(format.Block{Role: "assistant", ID: "b", Content: "Hi there"})
ag.AppendBlock(format.Block{Role: "call", ID: "c", Name: "shell", Content: `{"cmd":"ls"}`})
ag.ChatMu().RLock()
text := string(ag.TextLog())
ag.ChatMu().RUnlock()
// Should have user and assistant content
if !contains(text, "Hello") {
t.Error("should contain user message")
}
if !contains(text, "Hi there") {
t.Error("should contain assistant message")
}
// Should NOT have call content (hidden from rendered log)
if contains(text, "shell") || contains(text, "cmd") {
t.Error("call content should be hidden")
}
}
// Helper to split JSONL
func splitLines(data []byte) [][]byte {
var lines [][]byte
start := 0
for i, b := range data {
if b == '\n' {
lines = append(lines, data[start:i])
start = i + 1
}
}
if start < len(data) {
lines = append(lines, data[start:])
}
return lines
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}