Files
agent-bot 2cd52da85d
CI / go-vet (pull_request) Successful in 12s
CI / short-tests-report (pull_request) Failing after 35s
CI / backup-restore-drill (pull_request) Successful in 36s
CI / web (pull_request) Successful in 33s
refactor(squad): typed ProgressSink port replaces direct stream dependency
KTD1 second step (behavior preserved):
- squad defines ProgressSink (EmitPhase/Detail/Deliverable/Summary/Briefing)
  with its own DetailEvent/ToolEvent types; zero internal/stream imports left
- bizdev assembly maps port to SSE streamer (squad_progress_adapter) and
  injects via ctx at the four squad entry points
- process_bridge tests rewritten as port-contract tests (captureSink)
- verified: build+vet clean, squad/bizdev/archtest green
2026-09-29 05:51:32 +00:00

222 lines
6.1 KiB
Go

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)
}
}
}