Files
picoclaw/pkg/agent/subturn_test.go
T
Administrator ae23193295 feat(agent): port subturn PoC to refactor/agent branch
- Replace duplicate types (ToolResult/Session/Message) with real project types
- Implement ephemeralSessionStore satisfying session.SessionStore interface
- Connect runTurn to real AgentLoop via runAgentLoop + AgentInstance
- Fix subturn_test.go to match updated signatures and types

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
2026-03-16 14:31:32 +08:00

256 lines
6.5 KiB
Go

package agent
import (
"context"
"reflect"
"testing"
"github.com/sipeed/picoclaw/pkg/tools"
)
// ====================== Test Helper: Event Collector ======================
type eventCollector struct {
events []any
}
func (c *eventCollector) collect(e any) {
c.events = append(c.events, e)
}
func (c *eventCollector) hasEventOfType(typ any) bool {
targetType := reflect.TypeOf(typ)
for _, e := range c.events {
if reflect.TypeOf(e) == targetType {
return true
}
}
return false
}
func (c *eventCollector) countOfType(typ any) int {
targetType := reflect.TypeOf(typ)
count := 0
for _, e := range c.events {
if reflect.TypeOf(e) == targetType {
count++
}
}
return count
}
// ====================== Main Test Function ======================
func TestSpawnSubTurn(t *testing.T) {
tests := []struct {
name string
parentDepth int
config SubTurnConfig
wantErr error
wantSpawn bool
wantEnd bool
wantDepthFail bool
}{
{
name: "Basic success path - Single layer sub-turn",
parentDepth: 0,
config: SubTurnConfig{
Model: "gpt-4o-mini",
Tools: []tools.Tool{}, // At least one tool
},
wantErr: nil,
wantSpawn: true,
wantEnd: true,
},
{
name: "Nested 2 layers - Normal",
parentDepth: 1,
config: SubTurnConfig{
Model: "gpt-4o-mini",
Tools: []tools.Tool{},
},
wantErr: nil,
wantSpawn: true,
wantEnd: true,
},
{
name: "Depth limit triggered - 4th layer fails",
parentDepth: 3,
config: SubTurnConfig{
Model: "gpt-4o-mini",
Tools: []tools.Tool{},
},
wantErr: ErrDepthLimitExceeded,
wantSpawn: false,
wantEnd: false,
wantDepthFail: true,
},
{
name: "Invalid config - Empty Model",
parentDepth: 0,
config: SubTurnConfig{
Model: "",
Tools: []tools.Tool{},
},
wantErr: ErrInvalidSubTurnConfig,
wantSpawn: false,
wantEnd: false,
},
}
al, _, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Prepare parent Turn
parent := &turnState{
ctx: context.Background(),
turnID: "parent-1",
depth: tt.parentDepth,
childTurnIDs: []string{},
pendingResults: make(chan *tools.ToolResult, 10),
session: &ephemeralSessionStore{},
}
// Replace mock with test collector
collector := &eventCollector{}
originalEmit := MockEventBus.Emit
MockEventBus.Emit = collector.collect
defer func() { MockEventBus.Emit = originalEmit }()
// Execute spawnSubTurn
result, err := spawnSubTurn(context.Background(), al, parent, tt.config)
// Assert errors
if tt.wantErr != nil {
if err == nil || err != tt.wantErr {
t.Errorf("expected error %v, got %v", tt.wantErr, err)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
// Verify result
if result == nil {
t.Error("expected non-nil result")
}
// Verify event emission
if tt.wantSpawn {
if !collector.hasEventOfType(SubTurnSpawnEvent{}) {
t.Error("SubTurnSpawnEvent not emitted")
}
}
if tt.wantEnd {
if !collector.hasEventOfType(SubTurnEndEvent{}) {
t.Error("SubTurnEndEvent not emitted")
}
}
// Verify turn tree
if len(parent.childTurnIDs) == 0 && !tt.wantDepthFail {
t.Error("child Turn not added to parent.childTurnIDs")
}
// Verify result delivery (pendingResults or history)
if len(parent.pendingResults) > 0 || len(parent.session.GetHistory("")) > 0 {
// Result delivered via at least one path
} else {
t.Error("child result not delivered")
}
})
}
}
// ====================== Extra Independent Test: Ephemeral Session Isolation ======================
func TestSpawnSubTurn_EphemeralSessionIsolation(t *testing.T) {
al, _, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
parentSession := &ephemeralSessionStore{}
parentSession.AddMessage("", "user", "parent msg")
parent := &turnState{
ctx: context.Background(),
turnID: "parent-1",
depth: 0,
pendingResults: make(chan *tools.ToolResult, 1),
session: parentSession,
}
cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}}
// Record main session length before execution
originalLen := len(parent.session.GetHistory(""))
_, _ = spawnSubTurn(context.Background(), al, parent, cfg)
// After sub-turn ends, main session must remain unchanged
if len(parent.session.GetHistory("")) != originalLen {
t.Error("ephemeral session polluted the main session")
}
}
// ====================== Extra Independent Test: Result Delivery Path ======================
func TestSpawnSubTurn_ResultDelivery(t *testing.T) {
al, _, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()
parent := &turnState{
ctx: context.Background(),
turnID: "parent-1",
depth: 0,
pendingResults: make(chan *tools.ToolResult, 1),
session: &ephemeralSessionStore{},
}
cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}}
_, _ = spawnSubTurn(context.Background(), al, parent, cfg)
// Check if pendingResults received the result
select {
case res := <-parent.pendingResults:
if res == nil {
t.Error("received nil result in pendingResults")
}
default:
t.Error("result did not enter pendingResults")
}
}
// ====================== Extra Independent Test: Orphan Result Routing ======================
func TestSpawnSubTurn_OrphanResultRouting(t *testing.T) {
parentCtx, cancelParent := context.WithCancel(context.Background())
parent := &turnState{
ctx: parentCtx,
cancelFunc: cancelParent,
turnID: "parent-1",
depth: 0,
pendingResults: make(chan *tools.ToolResult, 1),
session: &ephemeralSessionStore{},
}
collector := &eventCollector{}
originalEmit := MockEventBus.Emit
MockEventBus.Emit = collector.collect
defer func() { MockEventBus.Emit = originalEmit }()
// Simulate parent finishing before child delivers result
parent.Finish()
// Call deliverSubTurnResult directly to simulate a delayed child
deliverSubTurnResult(parent, "delayed-child", &tools.ToolResult{ForLLM: "late result"})
// Verify Orphan event is emitted
if !collector.hasEventOfType(SubTurnOrphanResultEvent{}) {
t.Error("SubTurnOrphanResultEvent not emitted for finished parent")
}
// Verify history is NOT polluted
if len(parent.session.GetHistory("")) != 0 {
t.Error("Parent history was polluted by orphan result")
}
}