288 lines
7.0 KiB
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
|
|
}
|