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 }