/ internal / daemon / multi_handler_test.go
multi_handler_test.go
  1  package daemon
  2  
  3  import (
  4  	"testing"
  5  	"time"
  6  
  7  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
  8  )
  9  
 10  // spyHandler counts each callback. Used by all multiHandler tests.
 11  type spyHandler struct {
 12  	toolCalls      int
 13  	toolResults    int
 14  	text           int
 15  	streamDelta    int
 16  	approvalCalls  int
 17  	approvalReturn bool // what OnApprovalNeeded returns
 18  	usage          int
 19  	cloudAgent     int
 20  	cloudProgress  int
 21  	cloudPlan      int
 22  }
 23  
 24  func (s *spyHandler) OnToolCall(name, args string) { s.toolCalls++ }
 25  func (s *spyHandler) OnToolResult(name, args string, r agent.ToolResult, e time.Duration) {
 26  	s.toolResults++
 27  }
 28  func (s *spyHandler) OnText(t string)        { s.text++ }
 29  func (s *spyHandler) OnStreamDelta(d string) { s.streamDelta++ }
 30  func (s *spyHandler) OnApprovalNeeded(t, a string) bool {
 31  	s.approvalCalls++
 32  	return s.approvalReturn
 33  }
 34  func (s *spyHandler) OnUsage(u agent.TurnUsage)                        {}
 35  func (s *spyHandler) OnCloudAgent(id, status, msg string)              { s.cloudAgent++ }
 36  func (s *spyHandler) OnCloudProgress(done, total int)                  { s.cloudProgress++ }
 37  func (s *spyHandler) OnCloudPlan(pt, content string, needsReview bool) { s.cloudPlan++ }
 38  
 39  // usageSpy wraps spyHandler with a real OnUsage that increments the counter.
 40  type usageSpy struct {
 41  	spyHandler
 42  }
 43  
 44  func (u *usageSpy) OnUsage(turn agent.TurnUsage) { u.usage++ }
 45  
 46  func TestMultiHandlerFansOutBaseMethods(t *testing.T) {
 47  	a, b := &usageSpy{}, &usageSpy{}
 48  	m := &multiHandler{handlers: []agent.EventHandler{a, b}}
 49  
 50  	m.OnToolCall("bash", "ls")
 51  	m.OnToolResult("bash", "ls", agent.ToolResult{}, 0)
 52  	m.OnText("hi")
 53  	m.OnStreamDelta("d")
 54  	m.OnUsage(agent.TurnUsage{})
 55  	m.OnCloudAgent("id", "running", "msg")
 56  	m.OnCloudProgress(1, 2)
 57  	m.OnCloudPlan("research", "content", false)
 58  
 59  	for _, s := range []*usageSpy{a, b} {
 60  		if s.toolCalls != 1 || s.toolResults != 1 || s.text != 1 || s.streamDelta != 1 ||
 61  			s.usage != 1 || s.cloudAgent != 1 || s.cloudProgress != 1 || s.cloudPlan != 1 {
 62  			t.Fatalf("spy counts off: %+v", s.spyHandler)
 63  		}
 64  	}
 65  }
 66  
 67  func TestMultiHandlerApprovalORsResults(t *testing.T) {
 68  	a := &usageSpy{}
 69  	a.approvalReturn = false
 70  	b := &usageSpy{}
 71  	b.approvalReturn = true
 72  	m := &multiHandler{handlers: []agent.EventHandler{a, b}}
 73  
 74  	if !m.OnApprovalNeeded("bash", "rm -rf /") {
 75  		t.Fatal("want OR=true when any handler returns true")
 76  	}
 77  	if a.approvalCalls != 1 || b.approvalCalls != 1 {
 78  		t.Fatalf("approval calls: a=%d b=%d, want 1 each", a.approvalCalls, b.approvalCalls)
 79  	}
 80  
 81  	// When all return false, result is false.
 82  	c := &usageSpy{}
 83  	c.approvalReturn = false
 84  	d := &usageSpy{}
 85  	d.approvalReturn = false
 86  	m2 := &multiHandler{handlers: []agent.EventHandler{c, d}}
 87  	if m2.OnApprovalNeeded("bash", "ls") {
 88  		t.Fatal("want false when all handlers return false")
 89  	}
 90  }
 91  
 92  // Confirms multiHandler can be assigned to agent.EventHandler — both the loop
 93  // and tests pass it via that interface. A compile-time check keeps signature
 94  // drift loud.
 95  func TestMultiHandlerSatisfiesEventHandlerInterface(t *testing.T) {
 96  	var _ agent.EventHandler = (*multiHandler)(nil)
 97  }
 98  
 99  // sessIDSpy implements agent.EventHandler (via embedded spyHandler) AND SetSessionID.
100  // Used to verify multiHandler.SetSessionID propagates via type assertion.
101  type sessIDSpy struct {
102  	usageSpy
103  	receivedID string
104  }
105  
106  func (s *sessIDSpy) SetSessionID(id string) { s.receivedID = id }
107  
108  // plainSpy does NOT implement SetSessionID — used to verify the type assertion
109  // skips handlers cleanly rather than panicking.
110  type plainSpy struct {
111  	usageSpy
112  }
113  
114  func TestMultiHandlerSetSessionIDPropagatesToImplementers(t *testing.T) {
115  	setter := &sessIDSpy{}
116  	plain := &plainSpy{}
117  	m := &multiHandler{handlers: []agent.EventHandler{setter, plain}}
118  
119  	m.SetSessionID("sess_abc")
120  
121  	if setter.receivedID != "sess_abc" {
122  		t.Fatalf("setter.receivedID = %q, want sess_abc", setter.receivedID)
123  	}
124  	// plain has no SetSessionID; surviving the call without panic is the assertion.
125  	// Also verify the plain spy still receives base callbacks — the SetSessionID
126  	// bypass must not break the normal fan-out.
127  	m.OnText("x")
128  	if plain.text != 1 {
129  		t.Fatalf("plain.text = %d, want 1 — SetSessionID bypass must not break fan-out", plain.text)
130  	}
131  }
132  
133  // RunAgent type-asserts the top-level handler against the optional SetSessionID
134  // interface. multiHandler must itself satisfy that optional interface so the
135  // RunAgent assertion succeeds when the injected handler is a *multiHandler.
136  func TestMultiHandlerItselfImplementsSetSessionID(t *testing.T) {
137  	m := &multiHandler{}
138  	_, ok := interface{}(m).(interface{ SetSessionID(string) })
139  	if !ok {
140  		t.Fatal("multiHandler does not satisfy SetSessionID(string) interface")
141  	}
142  }
143  
144  // runStatusSpy implements agent.RunStatusHandler (and agent.EventHandler via the
145  // embedded usageSpy). Used to verify multiHandler.OnRunStatus propagates via
146  // type assertion.
147  type runStatusSpy struct {
148  	usageSpy
149  	calls []string // "code:detail"
150  }
151  
152  func (s *runStatusSpy) OnRunStatus(code, detail string) {
153  	s.calls = append(s.calls, code+":"+detail)
154  }
155  
156  func TestMultiHandlerOnRunStatusPropagatesToImplementers(t *testing.T) {
157  	rsh := &runStatusSpy{}
158  	plain := &plainSpy{}
159  	m := &multiHandler{handlers: []agent.EventHandler{rsh, plain}}
160  
161  	m.OnRunStatus("idle_soft", "15s idle")
162  
163  	if len(rsh.calls) != 1 || rsh.calls[0] != "idle_soft:15s idle" {
164  		t.Fatalf("rsh.calls = %+v", rsh.calls)
165  	}
166  	// plain has no OnRunStatus — call must not panic and not affect rsh.
167  }
168  
169  // Verify multiHandler satisfies agent.RunStatusHandler. The watchdog in the
170  // agent loop does `a.handler.(RunStatusHandler)` type assertion at
171  // runner.go:917-935; with multiHandler in the chain, the assertion must
172  // succeed so the watchdog can emit soft/hard idle events.
173  func TestMultiHandlerSatisfiesRunStatusHandlerInterface(t *testing.T) {
174  	var _ agent.RunStatusHandler = (*multiHandler)(nil) // compile-time check
175  }