package orchestration import ( "context" "errors" "fmt" "runtime" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm" ) func TestJoinMergesResults(t *testing.T) { t.Parallel() s := newScheduler(t, Options{MaxParallel: 2}) m := &mockRunner{result: &loop.Result{Text: "answer", Steps: 3, StopReason: "stop"}} s.runner = m ctx := context.Background() for i := 0; i < 3; i++ { if _, err := s.Spawn(ctx, AgentSpec{Goal: fmt.Sprintf("goal %d", i)}); err != nil { t.Fatalf("Spawn %d: %v", i, err) } } res, err := s.Join(ctx) if err != nil { t.Fatalf("Join: %v", err) } if res.Total != 3 || res.Succeeded != 3 || res.Failed != 0 || res.Aborted != 0 { t.Fatalf("merged = %d total / %d ok / %d failed / %d aborted, want 3/3/0/0", res.Total, res.Succeeded, res.Failed, res.Aborted) } if len(res.Agents) != 3 { t.Fatalf("Agents len = %d, want 3", len(res.Agents)) } for _, a := range res.Agents { if a.Status != StatusDone { t.Fatalf("agent %s status = %s, want done", a.ID, a.Status) } if a.Summary == "" { t.Fatalf("agent %s has no summary", a.ID) } if a.Text != "answer" || a.Steps != 3 { t.Fatalf("agent %s result = text %q steps %d, want answer/3", a.ID, a.Text, a.Steps) } } if !strings.Contains(res.Summary, "3 成功") { t.Fatalf("merged summary = %q, want a 3-成功 aggregate", res.Summary) } } func TestFailureSummary(t *testing.T) { t.Parallel() s := newScheduler(t, Options{MaxParallel: 2}) boom := errors.New("boom") m := &mockRunner{err: boom} s.runner = m sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"}) if err != nil { t.Fatalf("Spawn: %v", err) } waitFor(t, func() bool { return sub.Status() == StatusFailed }, waitTimeout) if !strings.Contains(sub.Summary(), "boom") { t.Fatalf("Summary = %q, want the failure recorded", sub.Summary()) } if !errors.Is(sub.Err(), boom) { t.Fatalf("Err = %v, want boom", sub.Err()) } if sub.Result() != nil { t.Fatalf("Result = %v, want nil on failure", sub.Result()) } res, err := s.Join(context.Background()) if err != nil { t.Fatalf("Join: %v", err) } if res.Total != 1 || res.Failed != 1 || res.Succeeded != 0 { t.Fatalf("merged = %d total / %d ok / %d failed, want 1/0/1", res.Total, res.Succeeded, res.Failed) } } func TestContextIsolation(t *testing.T) { t.Parallel() s := newScheduler(t, Options{MaxParallel: 1}) m := &mockRunner{result: &loop.Result{Text: "done"}} s.runner = m specCtx := []model.Message{model.TextMessage(model.RoleUser, "hello")} sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x", Context: specCtx}) if err != nil { t.Fatalf("Spawn: %v", err) } // Spawn 之后再改调用方的切片,不得泄漏进子代理。 specCtx[0].Content[0].Text = "mutated" res, err := s.Join(context.Background()) if err != nil { t.Fatalf("Join: %v", err) } if len(res.Agents) != 1 { t.Fatalf("Agents len = %d, want 1", len(res.Agents)) } if m.total != 1 { t.Fatalf("runner calls = %d, want 1", m.total) } // runner 收到的是隔离后的调用方上下文,加上任务本身作为末尾一条 user 消息 // (裁决 2026-10-04: 任务作为一条 user 消息注入). got := m.captured(0) if len(got) != 2 { t.Fatalf("runner received %d messages, want the isolated context + the task message: %#v", len(got), got) } if got[0].Role != model.RoleUser || got[0].Content[0].Text != "hello" { t.Fatalf("runner context[0] = %+v, want the isolated 'hello' user message", got[0]) } if got[1].Role != model.RoleUser || !strings.Contains(got[1].Content[0].Text, "x") { t.Fatalf("runner context[1] = %+v, want the task user message carrying the goal", got[1]) } if sub.Context[0].Content[0].Text != "hello" { t.Fatalf("sub-agent context text = %q, want hello", sub.Context[0].Content[0].Text) } } func TestProgressEvents(t *testing.T) { t.Parallel() s := newScheduler(t, Options{MaxParallel: 1}) gate := make(chan struct{}) m := &mockRunner{gate: gate} s.runner = m listener := comm.New(s.Bus(), "main") events := make(chan comm.Event, 8) unsub, err := listener.SubscribeEvent(context.Background(), func(_ context.Context, e comm.Event) error { events <- e return nil }) if err != nil { t.Fatalf("SubscribeEvent: %v", err) } defer unsub() if _, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"}); err != nil { t.Fatalf("Spawn: %v", err) } waitFor(t, func() bool { return m.activeCount() >= 1 }, waitTimeout) close(gate) names := collectEventNames(t, events, waitTimeout) seen := make(map[string]bool) for _, n := range names { seen[n] = true } for _, want := range []string{EventDispatched, EventStarted, EventDone} { if !seen[want] { t.Fatalf("missing progress event %q, got %v", want, names) } } } func TestJoin_CanceledCtx(t *testing.T) { t.Parallel() s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: 2 * time.Second}) m := &mockRunner{gate: make(chan struct{})} s.runner = m if _, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"}); err != nil { t.Fatalf("Spawn: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() res, err := s.Join(ctx) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("Join err = %v, want DeadlineExceeded", err) } if res == nil { t.Fatal("Join returned a nil merged result on canceled ctx") } } func TestApprove_NoApprover(t *testing.T) { t.Parallel() s := newScheduler(t, Options{}) if _, err := s.Approve(context.Background(), "bob", "run", "do something"); !errors.Is(err, ErrNoApprover) { t.Fatalf("Approve err = %v, want ErrNoApprover", err) } } func TestSinkMemory_NoStore(t *testing.T) { t.Parallel() s := newScheduler(t, Options{}) sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"}) if err != nil { t.Fatalf("Spawn: %v", err) } if _, err := s.SinkMemory(context.Background(), sub, "remember this", "persona", "relationship"); !errors.Is(err, ErrNoMemory) { t.Fatalf("SinkMemory err = %v, want ErrNoMemory", err) } } func TestRunWithRealLoop(t *testing.T) { t.Parallel() ad := &mockAdapter{resp: model.Response{Content: "final answer", Model: "mock"}} ag, err := agent.New("You are a helper.", nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } lp := loop.New(loop.Options{Agent: ag, MaxRounds: 3}) s := newScheduler(t, Options{Loop: lp, MaxParallel: 2}) sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"}) if err != nil { t.Fatalf("Spawn: %v", err) } waitFor(t, func() bool { return sub.Status() == StatusDone }, waitTimeout) if sub.Result() == nil { t.Fatal("real loop produced no result") } if sub.Result().Text != "final answer" { t.Fatalf("result text = %q, want final answer", sub.Result().Text) } res, err := s.Join(context.Background()) if err != nil { t.Fatalf("Join: %v", err) } if res.Succeeded != 1 || res.Failed != 0 { t.Fatalf("merged = %d ok / %d failed, want 1 ok", res.Succeeded, res.Failed) } } func TestNoGoroutineLeak(t *testing.T) { // 不并行:goroutine 计数断言在并行测试下噪声很大。 s := newScheduler(t, Options{MaxParallel: 2}) m := &mockRunner{result: &loop.Result{Text: "ok"}} s.runner = m baseline := runtime.NumGoroutine() ctx := context.Background() for i := 0; i < 20; i++ { if _, err := s.Spawn(ctx, AgentSpec{Goal: fmt.Sprintf("g%d", i)}); err != nil { t.Fatalf("Spawn %d: %v", i, err) } } if _, err := s.Join(ctx); err != nil { t.Fatalf("Join: %v", err) } waitFor(t, func() bool { return runtime.NumGoroutine() <= baseline+5 }, waitTimeout) }