165 lines
5.9 KiB
Go
165 lines
5.9 KiB
Go
package runtime
|
|
|
|
// Tests for DrainStream (architecture deepening ①): the single event-stream
|
|
// consumer shared by the REPL, the headless driver, and the sub-agent tool.
|
|
// These drive it with a hand-built LoopEventStream so the delta accounting and
|
|
// the message_update-vs-turn_end dispatch are exercised directly, independent
|
|
// of any provider.
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
)
|
|
|
|
// emitStream builds a LoopEventStream, runs emit on a producer goroutine to
|
|
// push events and set the result, and returns the stream ready to drain.
|
|
func emitStream(emit func(s *LoopEventStream)) *LoopEventStream {
|
|
s := agentcore.NewEventStream[agentcore.AgentEvent, []agentcore.AgentMessage](0)
|
|
go func() {
|
|
emit(s)
|
|
s.Close()
|
|
}()
|
|
return s
|
|
}
|
|
|
|
func assistantWith(text string) agentcore.AssistantMessage {
|
|
return agentcore.AssistantMessage{
|
|
RoleField: agentcore.RoleAssistant,
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
|
|
}
|
|
}
|
|
|
|
// TestDrainStreamTextDeltas verifies OnText receives only the new suffix of the
|
|
// streaming assistant message on each update, never re-emitting printed bytes.
|
|
func TestDrainStreamTextDeltas(t *testing.T) {
|
|
ctx := context.Background()
|
|
stream := emitStream(func(s *LoopEventStream) {
|
|
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("Hel")})
|
|
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("Hello")})
|
|
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("Hello world")})
|
|
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("Hello world")})
|
|
s.SetResult([]agentcore.AgentMessage{assistantWith("Hello world")})
|
|
})
|
|
|
|
var b strings.Builder
|
|
deltas := 0
|
|
final, err := DrainStream(ctx, stream, StreamHandler{
|
|
OnText: func(delta string) { b.WriteString(delta); deltas++ },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DrainStream: %v", err)
|
|
}
|
|
if got := b.String(); got != "Hello world" {
|
|
t.Errorf("concatenated deltas = %q, want %q", got, "Hello world")
|
|
}
|
|
// 3 update deltas ("Hel","lo","<space>world"); turn-end adds nothing new.
|
|
if deltas != 3 {
|
|
t.Errorf("OnText calls = %d, want 3 (turn-end must not re-emit)", deltas)
|
|
}
|
|
if final == nil || agentcore.ContentToText(final.Content) != "Hello world" {
|
|
t.Errorf("final message = %v, want %q", final, "Hello world")
|
|
}
|
|
}
|
|
|
|
// TestDrainStreamTurnEndFlush verifies that when a provider delivers only the
|
|
// complete message at turn end (no streaming updates), OnText still receives the
|
|
// full text via the turn-end flush.
|
|
func TestDrainStreamTurnEndFlush(t *testing.T) {
|
|
ctx := context.Background()
|
|
stream := emitStream(func(s *LoopEventStream) {
|
|
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("complete only at end")})
|
|
s.SetResult([]agentcore.AgentMessage{assistantWith("complete only at end")})
|
|
})
|
|
|
|
var b strings.Builder
|
|
_, err := DrainStream(ctx, stream, StreamHandler{OnText: func(d string) { b.WriteString(d) }})
|
|
if err != nil {
|
|
t.Fatalf("DrainStream: %v", err)
|
|
}
|
|
if got := b.String(); got != "complete only at end" {
|
|
t.Errorf("flushed text = %q, want full message", got)
|
|
}
|
|
}
|
|
|
|
// TestDrainStreamResetsPerTurn verifies the delta accounting resets between
|
|
// turns, so a second turn's text is emitted from its own start rather than being
|
|
// masked by the first turn's printed offset.
|
|
func TestDrainStreamResetsPerTurn(t *testing.T) {
|
|
ctx := context.Background()
|
|
stream := emitStream(func(s *LoopEventStream) {
|
|
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("first")})
|
|
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("first")})
|
|
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("two")})
|
|
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("two")})
|
|
s.SetResult([]agentcore.AgentMessage{assistantWith("two")})
|
|
})
|
|
|
|
var b strings.Builder
|
|
turns := 0
|
|
_, err := DrainStream(ctx, stream, StreamHandler{
|
|
OnText: func(d string) { b.WriteString(d) },
|
|
OnTurnEnd: func(agentcore.AssistantMessage, []agentcore.ToolResultMessage) { turns++ },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DrainStream: %v", err)
|
|
}
|
|
if got := b.String(); got != "firsttwo" {
|
|
t.Errorf("text across turns = %q, want %q", got, "firsttwo")
|
|
}
|
|
if turns != 2 {
|
|
t.Errorf("OnTurnEnd calls = %d, want 2", turns)
|
|
}
|
|
}
|
|
|
|
// TestDrainStreamOnTurnEndResults verifies tool results are handed to OnTurnEnd.
|
|
func TestDrainStreamOnTurnEndResults(t *testing.T) {
|
|
ctx := context.Background()
|
|
res := agentcore.ToolResultMessage{Content: agentcore.ContentList{agentcore.NewTextContent("42")}}
|
|
stream := emitStream(func(s *LoopEventStream) {
|
|
_ = s.Emit(ctx, agentcore.TurnEndEvent{
|
|
Message: assistantWith(""),
|
|
ToolResults: []agentcore.ToolResultMessage{res},
|
|
})
|
|
s.SetResult(nil)
|
|
})
|
|
|
|
var gotResults int
|
|
_, err := DrainStream(ctx, stream, StreamHandler{
|
|
OnTurnEnd: func(_ agentcore.AssistantMessage, rs []agentcore.ToolResultMessage) { gotResults = len(rs) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DrainStream: %v", err)
|
|
}
|
|
if gotResults != 1 {
|
|
t.Errorf("OnTurnEnd tool results = %d, want 1", gotResults)
|
|
}
|
|
}
|
|
|
|
// TestDrainStreamOnEvent verifies OnEvent sees every raw event in order (the
|
|
// stream-json driver's hook), and that a nil-callback handler still drains.
|
|
func TestDrainStreamOnEvent(t *testing.T) {
|
|
ctx := context.Background()
|
|
stream := emitStream(func(s *LoopEventStream) {
|
|
_ = s.Emit(ctx, agentcore.AgentStartEvent{})
|
|
_ = s.Emit(ctx, agentcore.MessageUpdateEvent{Message: assistantWith("x")})
|
|
_ = s.Emit(ctx, agentcore.TurnEndEvent{Message: assistantWith("x")})
|
|
_ = s.Emit(ctx, agentcore.AgentEndEvent{})
|
|
s.SetResult(nil)
|
|
})
|
|
|
|
var types []string
|
|
_, err := DrainStream(ctx, stream, StreamHandler{
|
|
OnEvent: func(ev agentcore.AgentEvent) { types = append(types, ev.EventType()) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DrainStream: %v", err)
|
|
}
|
|
want := []string{"agent_start", "message_update", "turn_end", "agent_end"}
|
|
if strings.Join(types, ",") != strings.Join(want, ",") {
|
|
t.Errorf("OnEvent order = %v, want %v", types, want)
|
|
}
|
|
}
|