test: add cancellation and AgentLoop lifecycle reporter tests
- pkg/tools/subagent_reporter_test.go: add blockingProvider stub and TestSubagentManager_Spawn_CancelledDuringExecution — verifies that context cancellation mid-LLM-call emits agent_gc(reason=cancelled) and clears the Broadcaster snapshot - pkg/agent/loop_reporter_test.go: new file — verifies main session and heartbeat session lifecycle events through a real Broadcaster: agent_spawn → agent_state(waiting) → agent_gc(completed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0205da79d0
commit
1daa2c135c
2 changed files with 222 additions and 0 deletions
132
pkg/agent/loop_reporter_test.go
Normal file
132
pkg/agent/loop_reporter_test.go
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/orch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeOrchTestLoop creates a minimal AgentLoop with a temp workspace and
|
||||||
|
// a real Broadcaster wired as the reporter.
|
||||||
|
// Returns the loop, the broadcaster, and a cleanup function.
|
||||||
|
func makeOrchTestLoop(t *testing.T) (*AgentLoop, *orch.Broadcaster) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-orch-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MkdirTemp: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.RemoveAll(tmpDir) })
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 512,
|
||||||
|
MaxToolIterations: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
b := orch.NewBroadcaster()
|
||||||
|
al.SetOrchReporter(b)
|
||||||
|
return al, b
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectOrchEvents drains the subscriber channel until an agent_gc event
|
||||||
|
// arrives or the deadline is exceeded.
|
||||||
|
func collectOrchEvents(t *testing.T, ch <-chan orch.Event, timeout time.Duration) []orch.Event {
|
||||||
|
t.Helper()
|
||||||
|
var events []orch.Event
|
||||||
|
deadline := time.After(timeout)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev := <-ch:
|
||||||
|
events = append(events, ev)
|
||||||
|
if ev.Type == "agent_gc" {
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out waiting for agent_gc; events so far: %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentLoop_ProcessDirect_EmitsSpawnWaitingGC verifies that a main
|
||||||
|
// session processed via ProcessDirect emits the full lifecycle:
|
||||||
|
//
|
||||||
|
// agent_spawn(sessionKey) → agent_state(waiting) → agent_gc(completed)
|
||||||
|
//
|
||||||
|
// and that the Broadcaster snapshot is empty after the call returns.
|
||||||
|
func TestAgentLoop_ProcessDirect_EmitsSpawnWaitingGC(t *testing.T) {
|
||||||
|
al, b := makeOrchTestLoop(t)
|
||||||
|
sub := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(sub)
|
||||||
|
|
||||||
|
const sessionKey = "orch-test-session"
|
||||||
|
_, err := al.ProcessDirect(context.Background(), "hello", sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessDirect: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := collectOrchEvents(t, sub.Ch, 5*time.Second)
|
||||||
|
|
||||||
|
// First event: agent_spawn with correct ID.
|
||||||
|
if events[0].Type != "agent_spawn" || events[0].ID != sessionKey {
|
||||||
|
t.Errorf("first event must be agent_spawn(%s), got: %+v", sessionKey, events[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one agent_state(waiting) for this session.
|
||||||
|
var hasWaiting bool
|
||||||
|
for _, ev := range events {
|
||||||
|
if ev.Type == "agent_state" && ev.ID == sessionKey && ev.State == "waiting" {
|
||||||
|
hasWaiting = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasWaiting {
|
||||||
|
t.Errorf("missing agent_state(waiting) for %s; events: %+v", sessionKey, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last event: agent_gc(completed) for this session.
|
||||||
|
last := events[len(events)-1]
|
||||||
|
if last.Type != "agent_gc" || last.ID != sessionKey || last.Reason != "completed" {
|
||||||
|
t.Errorf("last event must be agent_gc(completed,%s), got: %+v", sessionKey, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot must be empty — session removed on GC.
|
||||||
|
if snap := b.Snapshot(); len(snap) != 0 {
|
||||||
|
t.Errorf("snapshot must be empty after GC, got: %v", snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAgentLoop_ProcessHeartbeat_EmitsSpawnAndGC verifies that heartbeat
|
||||||
|
// sessions appear on canvas with sessionKey = "heartbeat".
|
||||||
|
func TestAgentLoop_ProcessHeartbeat_EmitsSpawnAndGC(t *testing.T) {
|
||||||
|
al, b := makeOrchTestLoop(t)
|
||||||
|
sub := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(sub)
|
||||||
|
|
||||||
|
_, err := al.ProcessHeartbeat(context.Background(), "check system", "heartbeat-chan", "none")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessHeartbeat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := collectOrchEvents(t, sub.Ch, 5*time.Second)
|
||||||
|
|
||||||
|
// ProcessHeartbeat always uses sessionKey = "heartbeat".
|
||||||
|
const want = "heartbeat"
|
||||||
|
if events[0].Type != "agent_spawn" || events[0].ID != want {
|
||||||
|
t.Errorf("first event must be agent_spawn(%s), got: %+v", want, events[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
last := events[len(events)-1]
|
||||||
|
if last.Type != "agent_gc" || last.ID != want || last.Reason != "completed" {
|
||||||
|
t.Errorf("last event must be agent_gc(completed,%s), got: %+v", want, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,28 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/orch"
|
"github.com/sipeed/picoclaw/pkg/orch"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// blockingProvider blocks inside Chat until the context is cancelled.
|
||||||
|
// The ready channel is closed the moment Chat is entered, so callers can
|
||||||
|
// synchronise before cancelling the context.
|
||||||
|
type blockingProvider struct {
|
||||||
|
ready chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBlockingProvider() *blockingProvider {
|
||||||
|
return &blockingProvider{ready: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *blockingProvider) Chat(ctx context.Context, _ []providers.Message, _ []providers.ToolDefinition, _ string, _ map[string]any) (*providers.LLMResponse, error) {
|
||||||
|
close(p.ready) // signal: we are now blocking
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *blockingProvider) GetDefaultModel() string { return "test" }
|
||||||
|
|
||||||
// TestSubagentManager_Spawn_EmitsLifecycleEvents verifies that Spawn() fires
|
// TestSubagentManager_Spawn_EmitsLifecycleEvents verifies that Spawn() fires
|
||||||
// the correct sequence of orchestration events through a real Broadcaster:
|
// the correct sequence of orchestration events through a real Broadcaster:
|
||||||
//
|
//
|
||||||
|
|
@ -151,3 +171,73 @@ func TestSubagentManager_Spawn_SnapshotLiveDuringExecution(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSubagentManager_Spawn_CancelledDuringExecution verifies that when the
|
||||||
|
// context is cancelled while a subagent's LLM call is in progress, the
|
||||||
|
// Broadcaster receives agent_gc with reason="cancelled" and the agent is
|
||||||
|
// removed from the snapshot.
|
||||||
|
//
|
||||||
|
// Synchronisation:
|
||||||
|
// 1. blockingProvider.ready is closed when Chat() is entered (goroutine is
|
||||||
|
// now blocked inside the LLM call).
|
||||||
|
// 2. Only then is the context cancelled, so there is no race between spawn
|
||||||
|
// and cancellation.
|
||||||
|
func TestSubagentManager_Spawn_CancelledDuringExecution(t *testing.T) {
|
||||||
|
b := orch.NewBroadcaster()
|
||||||
|
sub := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(sub)
|
||||||
|
|
||||||
|
bp := newBlockingProvider()
|
||||||
|
mgr := NewSubagentManager(bp, "test-model", "/tmp/test", nil, b)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := mgr.Spawn(ctx, "long task", "cancel-me", "", "cli", "direct", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Spawn() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until the subagent goroutine is inside Chat (blocking on ctx).
|
||||||
|
select {
|
||||||
|
case <-bp.ready:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for blockingProvider to enter Chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now cancel — the LLM call unblocks with ctx.Err().
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// Collect events until agent_gc.
|
||||||
|
var events []orch.Event
|
||||||
|
deadline := time.After(3 * time.Second)
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev := <-sub.Ch:
|
||||||
|
events = append(events, ev)
|
||||||
|
if ev.Type == "agent_gc" {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out waiting for agent_gc; events so far: %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate agent_gc and verify reason = "cancelled".
|
||||||
|
var gcEv orch.Event
|
||||||
|
for _, ev := range events {
|
||||||
|
if ev.Type == "agent_gc" {
|
||||||
|
gcEv = ev
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gcEv.Reason != "cancelled" {
|
||||||
|
t.Errorf("agent_gc reason = %q, want %q; events: %+v", gcEv.Reason, "cancelled", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot must be empty after the GC event.
|
||||||
|
if snap := b.Snapshot(); len(snap) != 0 {
|
||||||
|
t.Errorf("snapshot must be empty after agent_gc(cancelled), got: %v", snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue