- Introduced mechanisms to handle agent-to-agent (A2A) calls, including automatic history skipping for forked calls and proper source tracking. - Enhanced context management with the addition of ForkParentInfo to facilitate child stack creation without race conditions. - Updated JSAPI methods to ensure correct handling of sub-agent calls, maintaining output isolation and preventing history pollution. - Improved documentation to clarify the behavior of A2A calls and context management in concurrent scenarios.
401 lines
9.5 KiB
Go
401 lines
9.5 KiB
Go
package output
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// mockResponseWriter is a thread-safe mock for testing
|
|
type mockResponseWriter struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
header http.Header
|
|
flushed int
|
|
writeErr error
|
|
}
|
|
|
|
func newMockResponseWriter() *mockResponseWriter {
|
|
return &mockResponseWriter{
|
|
header: make(http.Header),
|
|
}
|
|
}
|
|
|
|
func (m *mockResponseWriter) Header() http.Header {
|
|
return m.header
|
|
}
|
|
|
|
func (m *mockResponseWriter) Write(data []byte) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.writeErr != nil {
|
|
return 0, m.writeErr
|
|
}
|
|
return m.buf.Write(data)
|
|
}
|
|
|
|
func (m *mockResponseWriter) WriteHeader(statusCode int) {}
|
|
|
|
func (m *mockResponseWriter) Flush() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.flushed++
|
|
}
|
|
|
|
func (m *mockResponseWriter) String() string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.buf.String()
|
|
}
|
|
|
|
func (m *mockResponseWriter) FlushCount() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.flushed
|
|
}
|
|
|
|
func TestSafeWriter_BasicWrite(t *testing.T) {
|
|
mock := newMockResponseWriter()
|
|
sw := NewSafeWriter(mock)
|
|
defer sw.Close()
|
|
|
|
// Write some data
|
|
n, err := sw.Write([]byte("hello"))
|
|
if err != nil {
|
|
t.Errorf("Write error: %v", err)
|
|
}
|
|
if n != 5 {
|
|
t.Errorf("Expected 5 bytes written, got %d", n)
|
|
}
|
|
|
|
// Wait for async write to complete
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
// Verify data was written
|
|
if got := mock.String(); got != "hello" {
|
|
t.Errorf("Expected 'hello', got '%s'", got)
|
|
}
|
|
}
|
|
|
|
func TestSafeWriter_ConcurrentWrites(t *testing.T) {
|
|
mock := newMockResponseWriter()
|
|
sw := NewSafeWriter(mock)
|
|
|
|
// Number of concurrent goroutines
|
|
numGoroutines := 100
|
|
// Number of writes per goroutine
|
|
writesPerGoroutine := 100
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(numGoroutines)
|
|
|
|
// Launch concurrent writes
|
|
for i := 0; i < numGoroutines; i++ {
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
for j := 0; j < writesPerGoroutine; j++ {
|
|
sw.Write([]byte("X"))
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines to complete
|
|
wg.Wait()
|
|
|
|
// Close and wait for all writes to be processed
|
|
sw.Close()
|
|
|
|
// Verify all data was written (no data loss)
|
|
expectedLen := numGoroutines * writesPerGoroutine
|
|
if got := len(mock.String()); got != expectedLen {
|
|
t.Errorf("Expected %d bytes, got %d", expectedLen, got)
|
|
}
|
|
|
|
// Verify flush was called (at least once per write)
|
|
if mock.FlushCount() < expectedLen {
|
|
t.Errorf("Expected at least %d flushes, got %d", expectedLen, mock.FlushCount())
|
|
}
|
|
}
|
|
|
|
func TestSafeWriter_NoDataCorruption(t *testing.T) {
|
|
mock := newMockResponseWriter()
|
|
sw := NewSafeWriter(mock)
|
|
|
|
// Use exactly 26 goroutines (one per letter A-Z) to avoid duplicates
|
|
numGoroutines := 26
|
|
// Message to write (with unique content per goroutine)
|
|
msgLen := 100
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(numGoroutines)
|
|
|
|
// Launch concurrent writes with different content
|
|
for i := 0; i < numGoroutines; i++ {
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
// Create a message with repeating character (unique per goroutine)
|
|
char := byte('A' + id)
|
|
msg := bytes.Repeat([]byte{char}, msgLen)
|
|
sw.Write(msg)
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines to complete
|
|
wg.Wait()
|
|
|
|
// Close and wait for all writes to be processed
|
|
sw.Close()
|
|
|
|
// Verify total length
|
|
result := mock.String()
|
|
expectedLen := numGoroutines * msgLen
|
|
if len(result) != expectedLen {
|
|
t.Errorf("Expected %d bytes, got %d", expectedLen, len(result))
|
|
}
|
|
|
|
// Verify no interleaving (each message should be contiguous)
|
|
// Check that we have exactly numGoroutines distinct blocks
|
|
blocks := make(map[byte]int)
|
|
for i := 0; i < len(result); i += msgLen {
|
|
if i+msgLen > len(result) {
|
|
t.Errorf("Unexpected data at end of result")
|
|
break
|
|
}
|
|
block := result[i : i+msgLen]
|
|
// Verify block is homogeneous (all same character)
|
|
firstChar := block[0]
|
|
for j, c := range []byte(block) {
|
|
if c != firstChar {
|
|
t.Errorf("Data corruption detected at position %d: expected %c, got %c", i+j, firstChar, c)
|
|
break
|
|
}
|
|
}
|
|
blocks[firstChar]++
|
|
}
|
|
|
|
// Each character should appear exactly once (one block per goroutine)
|
|
for char, count := range blocks {
|
|
if count != 1 {
|
|
t.Errorf("Character %c appeared %d times, expected 1", char, count)
|
|
}
|
|
}
|
|
|
|
// Verify we got all 26 letters
|
|
if len(blocks) != numGoroutines {
|
|
t.Errorf("Expected %d distinct blocks, got %d", numGoroutines, len(blocks))
|
|
}
|
|
}
|
|
|
|
func TestSafeWriter_CloseWaitsForPendingWrites(t *testing.T) {
|
|
mock := newMockResponseWriter()
|
|
sw := NewSafeWriter(mock)
|
|
|
|
// Write a large number of messages
|
|
numWrites := 1000
|
|
for i := 0; i < numWrites; i++ {
|
|
sw.Write([]byte("X"))
|
|
}
|
|
|
|
// Close should wait for all writes to complete
|
|
sw.Close()
|
|
|
|
// Verify all data was written
|
|
if got := len(mock.String()); got != numWrites {
|
|
t.Errorf("Expected %d bytes after close, got %d", numWrites, got)
|
|
}
|
|
}
|
|
|
|
func TestSafeWriter_WriteAfterClose(t *testing.T) {
|
|
mock := newMockResponseWriter()
|
|
sw := NewSafeWriter(mock)
|
|
|
|
sw.Write([]byte("before"))
|
|
sw.Close()
|
|
|
|
// Write after close should be silently ignored
|
|
n, err := sw.Write([]byte("after"))
|
|
if err != nil {
|
|
t.Errorf("Write after close should not error: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("Write after close should return 0, got %d", n)
|
|
}
|
|
|
|
// Verify only "before" was written
|
|
if got := mock.String(); got != "before" {
|
|
t.Errorf("Expected 'before', got '%s'", got)
|
|
}
|
|
}
|
|
|
|
func TestSafeWriter_ImplementsHTTPInterfaces(t *testing.T) {
|
|
mock := newMockResponseWriter()
|
|
sw := NewSafeWriter(mock)
|
|
defer sw.Close()
|
|
|
|
// Verify it implements http.ResponseWriter
|
|
var _ http.ResponseWriter = sw
|
|
|
|
// Verify it implements http.Flusher
|
|
var _ http.Flusher = sw
|
|
|
|
// Test Header()
|
|
sw.Header().Set("Content-Type", "text/plain")
|
|
if got := mock.Header().Get("Content-Type"); got != "text/plain" {
|
|
t.Errorf("Expected Content-Type 'text/plain', got '%s'", got)
|
|
}
|
|
}
|
|
|
|
// BenchmarkSafeWriter_ConcurrentWrites benchmarks concurrent write performance
|
|
func BenchmarkSafeWriter_ConcurrentWrites(b *testing.B) {
|
|
mock := newMockResponseWriter()
|
|
sw := NewSafeWriter(mock)
|
|
defer sw.Close()
|
|
|
|
data := []byte("benchmark data for SSE streaming")
|
|
|
|
b.RunParallel(func(pb *testing.PB) {
|
|
for pb.Next() {
|
|
sw.Write(data)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSafeWriter_RealHTTPServer tests SafeWriter with a real HTTP server
|
|
func TestSafeWriter_RealHTTPServer(t *testing.T) {
|
|
// This test verifies SafeWriter works correctly with httptest.ResponseRecorder
|
|
// which is commonly used in testing HTTP handlers
|
|
|
|
recorder := httptest.NewRecorder()
|
|
sw := NewSafeWriter(recorder)
|
|
|
|
// Simulate concurrent SSE writes from multiple sub-agents
|
|
var wg sync.WaitGroup
|
|
numAgents := 10
|
|
messagesPerAgent := 10
|
|
|
|
wg.Add(numAgents)
|
|
for i := 0; i < numAgents; i++ {
|
|
go func(agentID int) {
|
|
defer wg.Done()
|
|
for j := 0; j < messagesPerAgent; j++ {
|
|
// Simulate SSE message format
|
|
msg := []byte("data: {\"agent\":" + string(rune('0'+agentID)) + "}\n\n")
|
|
sw.Write(msg)
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
sw.Close()
|
|
|
|
// Verify response contains all messages (no data loss)
|
|
body := recorder.Body.String()
|
|
expectedMsgs := numAgents * messagesPerAgent
|
|
|
|
// Count number of "data: " prefixes
|
|
count := 0
|
|
for i := 0; i < len(body); i++ {
|
|
if i+6 <= len(body) && body[i:i+6] == "data: " {
|
|
count++
|
|
}
|
|
}
|
|
|
|
if count != expectedMsgs {
|
|
t.Errorf("Expected %d messages, found %d", expectedMsgs, count)
|
|
}
|
|
}
|
|
|
|
// TestSafeWriter_ContextCancellation tests that SafeWriter handles context cancellation
|
|
// This is critical for enterprise applications to prevent goroutine leaks
|
|
func TestSafeWriter_ContextCancellation(t *testing.T) {
|
|
mock := newMockResponseWriter()
|
|
|
|
// Create a cancellable context
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
sw := NewSafeWriterWithContext(ctx, mock)
|
|
|
|
// Write some data and wait for it to be processed
|
|
sw.Write([]byte("before"))
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
// Verify "before" was written
|
|
if got := mock.String(); got != "before" {
|
|
t.Errorf("Expected 'before' before cancel, got '%s'", got)
|
|
}
|
|
|
|
// Cancel context (simulates client disconnect)
|
|
cancel()
|
|
|
|
// Write after context cancellation - these may or may not be written
|
|
// depending on timing (select may pick ctx.Done() first)
|
|
sw.Write([]byte("after_cancel"))
|
|
|
|
// Close properly cleans up
|
|
sw.Close()
|
|
|
|
// After close, run() has exited
|
|
select {
|
|
case <-sw.done:
|
|
// Good - run() has exited
|
|
default:
|
|
t.Error("run() should have exited after Close()")
|
|
}
|
|
|
|
// The key guarantee: run() goroutine exits cleanly, no leak
|
|
// Data written before cancel is preserved
|
|
got := mock.String()
|
|
if len(got) < 6 { // At least "before" should be there
|
|
t.Errorf("Expected at least 'before', got '%s'", got)
|
|
}
|
|
}
|
|
|
|
// TestSafeWriter_GoroutineLeak tests that SafeWriter doesn't leak goroutines
|
|
func TestSafeWriter_GoroutineLeak(t *testing.T) {
|
|
// Create many SafeWriters and ensure they all clean up properly
|
|
numWriters := 100
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(numWriters)
|
|
|
|
for i := 0; i < numWriters; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
mock := newMockResponseWriter()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
sw := NewSafeWriterWithContext(ctx, mock)
|
|
|
|
// Write some data
|
|
sw.Write([]byte("test"))
|
|
|
|
// Randomly either close normally or cancel context
|
|
if time.Now().UnixNano()%2 == 0 {
|
|
cancel()
|
|
time.Sleep(5 * time.Millisecond)
|
|
sw.Close()
|
|
} else {
|
|
sw.Close()
|
|
cancel() // Cancel after close is safe
|
|
}
|
|
}()
|
|
}
|
|
|
|
// All goroutines should complete
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// All completed successfully
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("Timeout waiting for goroutines to complete - possible goroutine leak")
|
|
}
|
|
}
|