361 lines
9.6 KiB
Go
361 lines
9.6 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"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")
|
|
}
|
|
// Tool calls are now surfaced in the rendered log (name + args).
|
|
if !contains(text, "shell") {
|
|
t.Error("call should show tool name")
|
|
}
|
|
if !contains(text, "cmd") {
|
|
t.Error("call should show tool args")
|
|
}
|
|
}
|
|
|
|
// TestTextLog_IncrementalPartialNoDuplication verifies that assistant partials
|
|
// stream into textLog incrementally and that finalization does not duplicate the
|
|
// already-streamed body (only the trailing separator is appended).
|
|
func TestTextLog_IncrementalPartialNoDuplication(t *testing.T) {
|
|
ag := testAgent("session1", "agent1")
|
|
id := ag.NextBlockID()
|
|
|
|
// Stream partials: "Hel" -> "Hello" -> "Hello world"
|
|
ag.SetPartial(format.Block{Role: "assistant", ID: id, Content: "Hel", Partial: true})
|
|
if got := string(ag.TextLog()); got != "Hel" {
|
|
t.Fatalf("after first partial, textLog = %q, want %q", got, "Hel")
|
|
}
|
|
ag.SetPartial(format.Block{Role: "assistant", ID: id, Content: "Hello", Partial: true})
|
|
if got := string(ag.TextLog()); got != "Hello" {
|
|
t.Fatalf("after second partial, textLog = %q, want %q", got, "Hello")
|
|
}
|
|
ag.SetPartial(format.Block{Role: "assistant", ID: id, Content: "Hello world", Partial: true})
|
|
|
|
// Finalize.
|
|
ag.AppendBlock(format.Block{Role: "assistant", ID: id, Content: "Hello world"})
|
|
|
|
got := string(ag.TextLog())
|
|
if got != "Hello world\n\n" {
|
|
t.Errorf("textLog = %q, want %q (body must not be duplicated)", got, "Hello world\n\n")
|
|
}
|
|
if strings.Count(got, "Hello world") != 1 {
|
|
t.Errorf("body appears %d times, want 1: %q", strings.Count(got, "Hello world"), got)
|
|
}
|
|
}
|
|
|
|
// TestRawLogStream_NoLostWakeup verifies that a finalized block appended while
|
|
// a stream reader is between its data check and its wait is still delivered,
|
|
// without requiring a second event. This is the regression test for the TUI /
|
|
// last-message-lost race.
|
|
func TestRawLogStream_NoLostWakeup(t *testing.T) {
|
|
ag := testAgent("session1", "agent1")
|
|
ag.AppendBlock(format.Block{Role: "user", ID: "a", Content: "hi"})
|
|
|
|
// Catch up: fresh open returns the existing block and a sync base.
|
|
data, base, err := ag.RawLogStream(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("RawLogStream error: %v", err)
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("expected initial history")
|
|
}
|
|
|
|
// Reader is now caught up. Append concurrently while it blocks; the
|
|
// stream must return the new block promptly (not hang).
|
|
go func() {
|
|
ag.AppendBlock(format.Block{Role: "assistant", ID: "b", Content: "last message"})
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
data, _, err = ag.RawLogStream(ctx, base)
|
|
if err != nil {
|
|
t.Fatalf("RawLogStream error: %v", err)
|
|
}
|
|
if ctx.Err() != nil {
|
|
t.Fatal("stream blocked: final block was lost (lost wakeup)")
|
|
}
|
|
if !contains(string(data), "last message") {
|
|
t.Errorf("expected final block delivered, got %q", data)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|