/ internal / daemon / bus_handler_test.go
bus_handler_test.go
  1  package daemon
  2  
  3  import (
  4  	"encoding/json"
  5  	"strings"
  6  	"testing"
  7  	"time"
  8  
  9  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 10  )
 11  
 12  // Sanity check that busEventHandler satisfies both interfaces the agent loop
 13  // cares about. A compile-time assertion would work too, but a runtime check
 14  // plays nicely with the rest of the test file we're about to grow.
 15  func TestBusEventHandlerImplementsInterfaces(t *testing.T) {
 16  	var _ agent.EventHandler = (*busEventHandler)(nil)
 17  	var _ agent.RunStatusHandler = (*busEventHandler)(nil)
 18  }
 19  
 20  func TestBusEventHandlerSetSessionID(t *testing.T) {
 21  	h := &busEventHandler{}
 22  	h.SetSessionID("sess_123")
 23  	if h.sessionID != "sess_123" {
 24  		t.Fatalf("sessionID = %q, want %q", h.sessionID, "sess_123")
 25  	}
 26  }
 27  
 28  // newTestHandler returns a handler attached to a fresh bus; the caller uses
 29  // bus.Subscribe() to drain events. Shared by the remaining tests in this file.
 30  func newTestHandler(t *testing.T) (*busEventHandler, *EventBus) {
 31  	t.Helper()
 32  	bus := NewEventBus()
 33  	deps := &ServerDeps{EventBus: bus}
 34  	h := &busEventHandler{deps: deps, agent: "coding"}
 35  	h.SetSessionID("sess_test")
 36  	return h, bus
 37  }
 38  
 39  // drain reads up to `want` events from the bus subscription within 250ms;
 40  // returns whatever it got. Callers assert length + contents.
 41  func drain(t *testing.T, ch <-chan Event, want int) []Event {
 42  	t.Helper()
 43  	out := make([]Event, 0, want)
 44  	deadline := time.After(250 * time.Millisecond)
 45  	for len(out) < want {
 46  		select {
 47  		case evt := <-ch:
 48  			out = append(out, evt)
 49  		case <-deadline:
 50  			return out
 51  		}
 52  	}
 53  	return out
 54  }
 55  
 56  func TestBusEventHandlerOnToolCallEmitsRunning(t *testing.T) {
 57  	h, bus := newTestHandler(t)
 58  	ch := bus.Subscribe()
 59  	defer bus.Unsubscribe(ch)
 60  
 61  	h.OnToolCall("bash", "ls -la /tmp")
 62  
 63  	got := drain(t, ch, 1)
 64  	if len(got) != 1 {
 65  		t.Fatalf("got %d events, want 1", len(got))
 66  	}
 67  	evt := got[0]
 68  	if evt.Type != EventToolStatus {
 69  		t.Fatalf("event type = %q, want %q", evt.Type, EventToolStatus)
 70  	}
 71  
 72  	var p struct {
 73  		Tool      string `json:"tool"`
 74  		Status    string `json:"status"`
 75  		Args      string `json:"args"`
 76  		SessionID string `json:"session_id"`
 77  		TS        string `json:"ts"`
 78  	}
 79  	if err := json.Unmarshal(evt.Payload, &p); err != nil {
 80  		t.Fatalf("unmarshal: %v", err)
 81  	}
 82  	if p.Tool != "bash" || p.Status != "running" {
 83  		t.Fatalf("bad tool/status: %+v", p)
 84  	}
 85  	if p.Args != "ls -la /tmp" {
 86  		t.Fatalf("args = %q, want unchanged", p.Args)
 87  	}
 88  	if p.SessionID != "sess_test" {
 89  		t.Fatalf("session_id = %q", p.SessionID)
 90  	}
 91  	if p.TS == "" {
 92  		t.Fatalf("ts missing")
 93  	}
 94  }
 95  
 96  func TestBusEventHandlerOnToolCallRedactsAndTruncatesArgs(t *testing.T) {
 97  	h, bus := newTestHandler(t)
 98  	ch := bus.Subscribe()
 99  	defer bus.Unsubscribe(ch)
100  
101  	long := "curl -H 'Authorization: Bearer sk-secretvalue1234567890' https://api.example.com/" + strings.Repeat("x", 500)
102  	h.OnToolCall("bash", long)
103  
104  	got := drain(t, ch, 1)
105  	if len(got) != 1 {
106  		t.Fatalf("want 1 event")
107  	}
108  	var p struct {
109  		Args string `json:"args"`
110  	}
111  	_ = json.Unmarshal(got[0].Payload, &p)
112  
113  	if strings.Contains(p.Args, "sk-secretvalue") {
114  		t.Fatalf("secret leaked: %q", p.Args)
115  	}
116  	if !strings.Contains(p.Args, "[REDACTED]") {
117  		t.Fatalf("expected [REDACTED] marker, got %q", p.Args)
118  	}
119  	if len(p.Args) > 200 {
120  		t.Fatalf("args len = %d, want ≤ 200", len(p.Args))
121  	}
122  }
123  
124  func TestBusEventHandlerOnToolResultEmitsCompleted(t *testing.T) {
125  	h, bus := newTestHandler(t)
126  	ch := bus.Subscribe()
127  	defer bus.Unsubscribe(ch)
128  
129  	result := agent.ToolResult{
130  		Content: "total 12\ndrwxr-x...",
131  		IsError: false,
132  	}
133  	h.OnToolResult("bash", "ls", result, 1234*time.Millisecond)
134  
135  	got := drain(t, ch, 1)
136  	if len(got) != 1 {
137  		t.Fatalf("want 1 event")
138  	}
139  
140  	var p struct {
141  		Tool      string  `json:"tool"`
142  		Status    string  `json:"status"`
143  		Elapsed   float64 `json:"elapsed"`
144  		IsError   bool    `json:"is_error"`
145  		Preview   string  `json:"preview"`
146  		SessionID string  `json:"session_id"`
147  	}
148  	_ = json.Unmarshal(got[0].Payload, &p)
149  	if p.Status != "completed" {
150  		t.Fatalf("status = %q, want completed", p.Status)
151  	}
152  	if p.Elapsed != 1.234 {
153  		t.Fatalf("elapsed = %v, want 1.234", p.Elapsed)
154  	}
155  	if p.IsError {
156  		t.Fatalf("is_error = true, want false")
157  	}
158  	if !strings.HasPrefix(p.Preview, "total 12") {
159  		t.Fatalf("preview = %q", p.Preview)
160  	}
161  }
162  
163  func TestBusEventHandlerOnToolResultTruncatesPreview(t *testing.T) {
164  	h, bus := newTestHandler(t)
165  	ch := bus.Subscribe()
166  	defer bus.Unsubscribe(ch)
167  
168  	longText := strings.Repeat("x", 500)
169  	result := agent.ToolResult{Content: longText}
170  	h.OnToolResult("bash", "", result, 0)
171  
172  	got := drain(t, ch, 1)
173  	var p struct {
174  		Preview string `json:"preview"`
175  	}
176  	_ = json.Unmarshal(got[0].Payload, &p)
177  	if len(p.Preview) > 200 {
178  		t.Fatalf("preview len = %d, want ≤ 200", len(p.Preview))
179  	}
180  }
181  
182  func TestBusEventHandlerOnToolResultPropagatesIsError(t *testing.T) {
183  	h, bus := newTestHandler(t)
184  	ch := bus.Subscribe()
185  	defer bus.Unsubscribe(ch)
186  
187  	h.OnToolResult("bash", "", agent.ToolResult{
188  		Content: "command not found",
189  		IsError: true,
190  	}, 5*time.Millisecond)
191  
192  	got := drain(t, ch, 1)
193  	if len(got) != 1 {
194  		t.Fatalf("want 1 event, got %d", len(got))
195  	}
196  	var p struct {
197  		IsError bool `json:"is_error"`
198  	}
199  	if err := json.Unmarshal(got[0].Payload, &p); err != nil {
200  		t.Fatalf("unmarshal: %v", err)
201  	}
202  	if !p.IsError {
203  		t.Fatalf("is_error = false, want true")
204  	}
205  }
206  
207  func TestBusEventHandlerOnToolCallRedactsSecretSpanningTruncation(t *testing.T) {
208  	h, bus := newTestHandler(t)
209  	ch := bus.Subscribe()
210  	defer bus.Unsubscribe(ch)
211  
212  	// AKIA is fixed-length (`AKIA[0-9A-Z]{16}` = exactly 20 chars), unlike
213  	// `Bearer ...` which is greedy and would match a truncated fragment too.
214  	// Position it to straddle the 200-byte cap: 185 filler + 20 secret + tail.
215  	// truncate-then-redact would see only "AKIA" + 15 chars, miss the {16}
216  	// requirement, and leak the prefix. redact-then-truncate matches the full
217  	// pattern before truncation, substitutes [REDACTED], and truncates cleanly.
218  	input := strings.Repeat("a", 185) + "AKIAABCDEFGHIJKLMNOP" + strings.Repeat("z", 100)
219  	h.OnToolCall("bash", input)
220  
221  	got := drain(t, ch, 1)
222  	if len(got) != 1 {
223  		t.Fatalf("want 1 event")
224  	}
225  	var p struct {
226  		Args string `json:"args"`
227  	}
228  	_ = json.Unmarshal(got[0].Payload, &p)
229  	if strings.Contains(p.Args, "AKIAABCDE") {
230  		t.Fatalf("secret leaked across truncation boundary: %q", p.Args)
231  	}
232  	if !strings.Contains(p.Args, "[REDACTED]") {
233  		t.Fatalf("expected [REDACTED] marker, got %q", p.Args)
234  	}
235  	if len(p.Args) > 200 {
236  		t.Fatalf("args len = %d, want ≤ 200", len(p.Args))
237  	}
238  }
239  
240  func TestBusEventHandlerOnUsageEmitsSnapshot(t *testing.T) {
241  	h, bus := newTestHandler(t)
242  	ch := bus.Subscribe()
243  	defer bus.Unsubscribe(ch)
244  
245  	u := agent.TurnUsage{
246  		InputTokens:         1200,
247  		OutputTokens:        450,
248  		CostUSD:             0.012,
249  		LLMCalls:            3,
250  		Model:               "claude-sonnet-4-6",
251  		CacheReadTokens:     800,
252  		CacheCreationTokens: 0,
253  	}
254  	h.OnUsage(u)
255  
256  	got := drain(t, ch, 1)
257  	if len(got) != 1 {
258  		t.Fatalf("want 1 event, got %d", len(got))
259  	}
260  	if got[0].Type != EventUsage {
261  		t.Fatalf("type = %q, want %q", got[0].Type, EventUsage)
262  	}
263  
264  	var p struct {
265  		InputTokens      int     `json:"input_tokens"`
266  		OutputTokens     int     `json:"output_tokens"`
267  		CacheReadTokens  int     `json:"cache_read_tokens"`
268  		CacheWriteTokens int     `json:"cache_write_tokens"`
269  		CostUSD          float64 `json:"cost_usd"`
270  		LLMCalls         int     `json:"llm_calls"`
271  		Model            string  `json:"model"`
272  		SessionID        string  `json:"session_id"`
273  		TS               string  `json:"ts"`
274  	}
275  	_ = json.Unmarshal(got[0].Payload, &p)
276  	if p.InputTokens != 1200 || p.OutputTokens != 450 {
277  		t.Fatalf("tokens = %+v", p)
278  	}
279  	if p.CacheReadTokens != 800 || p.CacheWriteTokens != 0 {
280  		t.Fatalf("cache tokens = %+v", p)
281  	}
282  	if p.LLMCalls != 3 {
283  		t.Fatalf("llm_calls = %d, want 3", p.LLMCalls)
284  	}
285  	if p.Model != "claude-sonnet-4-6" {
286  		t.Fatalf("model = %q", p.Model)
287  	}
288  	if p.SessionID != "sess_test" {
289  		t.Fatalf("session_id = %q", p.SessionID)
290  	}
291  	if p.CostUSD != 0.012 {
292  		t.Fatalf("cost_usd = %v, want 0.012", p.CostUSD)
293  	}
294  	if p.TS == "" {
295  		t.Fatalf("ts missing")
296  	}
297  }
298  
299  func TestBusEventHandlerOnCloudAgent(t *testing.T) {
300  	h, bus := newTestHandler(t)
301  	ch := bus.Subscribe()
302  	defer bus.Unsubscribe(ch)
303  
304  	h.OnCloudAgent("research_a", "running", "searching papers")
305  
306  	got := drain(t, ch, 1)
307  	if len(got) != 1 || got[0].Type != EventCloudAgent {
308  		t.Fatalf("events = %+v", got)
309  	}
310  	var p struct {
311  		AgentID   string `json:"agent_id"`
312  		Status    string `json:"status"`
313  		Message   string `json:"message"`
314  		SessionID string `json:"session_id"`
315  	}
316  	_ = json.Unmarshal(got[0].Payload, &p)
317  	if p.AgentID != "research_a" {
318  		t.Fatalf("agent_id = %q", p.AgentID)
319  	}
320  	if p.Status != "running" {
321  		t.Fatalf("status = %q", p.Status)
322  	}
323  	if p.Message != "searching papers" {
324  		t.Fatalf("message = %q", p.Message)
325  	}
326  	if p.SessionID != "sess_test" {
327  		t.Fatalf("session_id = %q", p.SessionID)
328  	}
329  }
330  
331  func TestBusEventHandlerOnCloudProgress(t *testing.T) {
332  	h, bus := newTestHandler(t)
333  	ch := bus.Subscribe()
334  	defer bus.Unsubscribe(ch)
335  
336  	h.OnCloudProgress(3, 7)
337  
338  	got := drain(t, ch, 1)
339  	if len(got) != 1 || got[0].Type != EventCloudProgress {
340  		t.Fatalf("events = %+v", got)
341  	}
342  	var p struct {
343  		Completed int    `json:"completed"`
344  		Total     int    `json:"total"`
345  		SessionID string `json:"session_id"`
346  	}
347  	_ = json.Unmarshal(got[0].Payload, &p)
348  	if p.Completed != 3 {
349  		t.Fatalf("completed = %d, want 3", p.Completed)
350  	}
351  	if p.Total != 7 {
352  		t.Fatalf("total = %d, want 7", p.Total)
353  	}
354  	if p.SessionID != "sess_test" {
355  		t.Fatalf("session_id = %q", p.SessionID)
356  	}
357  }
358  
359  func TestBusEventHandlerOnCloudPlanTruncatesContent(t *testing.T) {
360  	h, bus := newTestHandler(t)
361  	ch := bus.Subscribe()
362  	defer bus.Unsubscribe(ch)
363  
364  	long := strings.Repeat("x", 5000) // 5KB input — must be capped near 2KB
365  	h.OnCloudPlan("research", long, true)
366  
367  	got := drain(t, ch, 1)
368  	if len(got) != 1 || got[0].Type != EventCloudPlan {
369  		t.Fatalf("events = %+v", got)
370  	}
371  	var p struct {
372  		Type        string `json:"type"`
373  		Content     string `json:"content"`
374  		NeedsReview bool   `json:"needs_review"`
375  		SessionID   string `json:"session_id"`
376  	}
377  	_ = json.Unmarshal(got[0].Payload, &p)
378  	if p.Type != "research" {
379  		t.Fatalf("type = %q, want research", p.Type)
380  	}
381  	if !p.NeedsReview {
382  		t.Fatalf("needs_review = false, want true")
383  	}
384  	if p.SessionID != "sess_test" {
385  		t.Fatalf("session_id = %q", p.SessionID)
386  	}
387  	// Body must be truncated: original 5000 bytes → capped at 2048 + "… (truncated)" marker
388  	if len(p.Content) > 2100 { // 2048 + marker slack
389  		t.Fatalf("content len = %d, want ≤ ~2100", len(p.Content))
390  	}
391  	if !strings.HasSuffix(p.Content, "… (truncated)") {
392  		t.Fatalf("content does not end with truncation marker: %q", p.Content[len(p.Content)-30:])
393  	}
394  }
395  
396  // Guards the opposite path: content under 2KB must NOT have the truncation marker
397  // appended. Together with TestBusEventHandlerOnCloudPlanTruncatesContent this
398  // locks the cap's threshold behavior.
399  func TestBusEventHandlerOnCloudPlanShortContentNotTruncated(t *testing.T) {
400  	h, bus := newTestHandler(t)
401  	ch := bus.Subscribe()
402  	defer bus.Unsubscribe(ch)
403  
404  	h.OnCloudPlan("analysis", "short plan body", false)
405  
406  	got := drain(t, ch, 1)
407  	if len(got) != 1 {
408  		t.Fatalf("want 1 event, got %d", len(got))
409  	}
410  	var p struct {
411  		Content     string `json:"content"`
412  		NeedsReview bool   `json:"needs_review"`
413  	}
414  	_ = json.Unmarshal(got[0].Payload, &p)
415  	if p.Content != "short plan body" {
416  		t.Fatalf("content = %q, want unchanged", p.Content)
417  	}
418  	if p.NeedsReview {
419  		t.Fatalf("needs_review = true, want false")
420  	}
421  }
422  
423  func TestBusEventHandlerOnRunStatus(t *testing.T) {
424  	h, bus := newTestHandler(t)
425  	ch := bus.Subscribe()
426  	defer bus.Unsubscribe(ch)
427  
428  	h.OnRunStatus("idle_soft", "no LLM activity for 15s (phase=awaiting_llm)")
429  
430  	got := drain(t, ch, 1)
431  	if len(got) != 1 || got[0].Type != EventRunStatus {
432  		t.Fatalf("events = %+v", got)
433  	}
434  	var p struct {
435  		Code      string `json:"code"`
436  		Detail    string `json:"detail"`
437  		SessionID string `json:"session_id"`
438  		Agent     string `json:"agent"`
439  	}
440  	_ = json.Unmarshal(got[0].Payload, &p)
441  	if p.Code != "idle_soft" {
442  		t.Fatalf("code = %q", p.Code)
443  	}
444  	if !strings.Contains(p.Detail, "no LLM activity") {
445  		t.Fatalf("detail = %q", p.Detail)
446  	}
447  	if p.SessionID != "sess_test" {
448  		t.Fatalf("session_id = %q", p.SessionID)
449  	}
450  	if p.Agent != "coding" {
451  		t.Fatalf("agent = %q, want 'coding'", p.Agent)
452  	}
453  }