package squad import ( "strings" "sync" "testing" "time" "chat-agent-system/internal/agent" ) // captureSink 端口契约测试辅助:并发安全地捕获 DetailEvent。 type captureSink struct { mu *sync.Mutex events *[]DetailEvent } func (c *captureSink) EmitPhase(phaseID, message, role string, stepIndex, totalSteps, progress int) {} func (c *captureSink) EmitDetail(p DetailEvent) { c.mu.Lock() *c.events = append(*c.events, p) c.mu.Unlock() } func (c *captureSink) EmitDeliverable(taskKey, title, filePath string, wordCount int, score float64) {} func (c *captureSink) EmitSummary(title, content string, maturityScore int, maturityLevel string) {} func (c *captureSink) EmitBriefing(brief any) {} func newCaptureSink() (*captureSink, *[]DetailEvent, *sync.Mutex) { var mu sync.Mutex var events []DetailEvent return &captureSink{mu: &mu, events: &events}, &events, &mu } func TestProcessBridgeNilStreamerIsNoop(t *testing.T) { step := StepSpec{ID: "step_01", Role: "regulatory"} req := &agent.Request{} stop := AttachProcessBridge(nil, step, req) if req.OnEvent != nil { t.Fatal("nil sink must not attach OnEvent") } req.OnEvent = func(agent.ProcessEvent) {} // 直接赋值不应有 panic stop() // no-op stop 不 panic } func TestProcessBridgeReasoningFlow(t *testing.T) { sink, events, mu := newCaptureSink() step := StepSpec{ID: "step_02", Role: "engineer"} req := &agent.Request{} stop := AttachProcessBridge(sink, step, req) defer stop() req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "第一段思考"}) req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "继续推理"}) req.OnEvent(agent.ProcessEvent{Type: "message_end"}) stop() // 再发事件(stop 后)不应 panic 且不应再发帧 req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "ignored"}) mu.Lock() defer mu.Unlock() var deltas, dones int var joined strings.Builder for _, ev := range *events { if ev.Category != detailCategoryAgentThought { t.Fatalf("unexpected category %s", ev.Category) } if ev.Delta != "" { deltas++ joined.WriteString(ev.Delta) } if ev.Done { dones++ } if ev.StepID != step.ID || ev.Role != step.Role { t.Fatalf("step identity missing: %+v", ev) } } if joined.String() != "第一段思考继续推理" { t.Fatalf("joined delta %q", joined.String()) } if dones != 1 { t.Fatalf("expected exactly 1 done frame, got %d", dones) } if deltas < 1 { t.Fatal("expected at least one delta frame") } } func TestProcessBridgeToolFlow(t *testing.T) { sink, events, mu := newCaptureSink() step := StepSpec{ID: "step_03", Role: "writer"} b := newProcessBridge(sink, step, "撰写专员") if b == nil { t.Fatal("bridge expected") } b.Handle(agent.ProcessEvent{Type: "tool_call_delta", Tool: "read_file", Detail: `{"path":"a.md"`}) b.Handle(agent.ProcessEvent{Type: "tool_execution_start", Tool: "read_file"}) b.Handle(agent.ProcessEvent{Type: "tool_call_delta", Tool: "read_file", Detail: `}`}) b.Handle(agent.ProcessEvent{Type: "tool_execution_end", Tool: "read_file", Detail: "file content ..."}) b.Stop() mu.Lock() defer mu.Unlock() var calling, done, withInput int for _, ev := range *events { if ev.Category != detailCategoryToolCall { continue } if ev.Tool == nil { t.Fatal("tool_call frame must carry Tool info") } switch { case ev.Tool.Status == "calling": calling++ case ev.Done: done++ } if strings.Contains(ev.Tool.Input, "a.md") { withInput++ } } if calling != 1 { t.Fatalf("calling frames %d", calling) } if done < 1 { t.Fatal("expected done frame for tool item") } if withInput == 0 { t.Fatal("tool input aggregate lost") } } func TestProcessBridgeToolFailureStatus(t *testing.T) { sink, events, mu := newCaptureSink() b := newProcessBridge(sink, StepSpec{ID: "s", Role: "r"}, "测试") b.Handle(agent.ProcessEvent{Type: "tool_execution_start", Tool: "write_file"}) b.Handle(agent.ProcessEvent{Type: "tool_execution_end", Tool: "write_file", Detail: "error: disk full"}) b.Stop() mu.Lock() defer mu.Unlock() sawFailed := false for _, ev := range *events { if ev.Tool != nil && ev.Tool.Status == "failed" { sawFailed = true } } if !sawFailed { t.Fatal("error result must map to failed status") } } func TestTrimProcessText(t *testing.T) { small := strings.Repeat("a", 100) if got := trimProcessText(small); got != small { t.Fatal("small text must pass through") } big := strings.Repeat("b", processToolTrimBytes*4) got := trimProcessText(big) if len(got) >= len(big) { t.Fatal("big text must be trimmed") } if !strings.Contains(got, "…(中间省略") { t.Fatal("trim marker missing") } } func TestProcessBridgeConcurrentSteps(t *testing.T) { // 并行组成员各自持有独立 bridge,事件互不串扰(设计 F.4)。 var mu sync.Mutex seen := map[string]string{} // itemID -> role sink := &captureSink{mu: &mu} sink.events = &[]DetailEvent{} capture := func(p DetailEvent) { sink.mu.Lock() *sink.events = append(*sink.events, p) sink.mu.Unlock() } _ = capture b1 := newProcessBridge(sink, StepSpec{ID: "p1", Role: "regulatory"}, "法规专员") b2 := newProcessBridge(sink, StepSpec{ID: "p2", Role: "market"}, "市场专员") var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() for i := 0; i < 20; i++ { b1.Handle(agent.ProcessEvent{Type: "reasoning_delta", Text: "r1"}) } b1.Stop() }() go func() { defer wg.Done() for i := 0; i < 20; i++ { b2.Handle(agent.ProcessEvent{Type: "reasoning_delta", Text: "r2"}) } b2.Stop() }() wg.Wait() time.Sleep(50 * time.Millisecond) // 等冲刷 // 从捕获事件推导 itemID -> role 映射 mu.Lock() for _, ev := range *sink.events { seen[ev.ItemID] = ev.Role } mu.Unlock() for itemID, role := range seen { if !strings.Contains(itemID, "p1") && !strings.Contains(itemID, "p2") { t.Fatalf("unexpected item %s", itemID) } want := "regulatory" if strings.Contains(itemID, "p2") { want = "market" } if role != want { t.Fatalf("item %s mapped to role %s, want %s", itemID, role, want) } } }