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
222 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|