Files
Cyrene_v2/internal/runtime/loop/loop_test.go
T
AskaEth 91e8d7a029 docs(runtime): internal/runtime/loop 44 个文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;本模块按长期分批协议(一个模块一次派工)执行。
注释行数 1329 → 1329 逐行 1:1(含中文 33 → 1304);行尾注释 5 条一并译。
校验:机械校验(非注释改动行)= 空;独立 Go AST 程序(剥注释复渲)证明 44/44 文件代码全等;
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
gofmt:capacity_loop_test.go / flow.go 改动前即未格式化,按铁律保留原样未动。
2026-10-03 22:28:21 +08:00

379 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
func TestRun_NoTools_FinalText(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Model: "m", Content: "Hello!"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, MaxRounds: 8})
res, err := l.Run(context.Background(), "sess-1", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "Hello!" {
t.Fatalf("Text = %q, want Hello!", res.Text)
}
if res.Steps != 1 {
t.Fatalf("Steps = %d, want 1", res.Steps)
}
if res.StopReason != agent.StopReasonStop {
t.Fatalf("StopReason = %q, want %q", res.StopReason, agent.StopReasonStop)
}
if res.ToolCallsExecuted != 0 {
t.Fatalf("ToolCallsExecuted = %d, want 0", res.ToolCallsExecuted)
}
}
func TestRun_ToolCallThenFinal_FeedsBack(t *testing.T) {
t.Parallel()
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "Echoed!"}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "echo this")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "Echoed!" {
t.Fatalf("Text = %q, want Echoed!", res.Text)
}
if res.Steps != 2 {
t.Fatalf("Steps = %d, want 2", res.Steps)
}
if res.StopReason != agent.StopReasonStop {
t.Fatalf("StopReason = %q, want stop", res.StopReason)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
if len(ad.reqs) < 2 {
t.Fatalf("adapter requests = %d, want >= 2", len(ad.reqs))
}
last := ad.reqs[len(ad.reqs)-1]
got := last.Messages[len(last.Messages)-1]
if got.Role != model.RoleTool {
t.Fatalf("last message role = %q, want tool", got.Role)
}
if !strings.Contains(got.Content[0].Text, "hi") {
t.Fatalf("tool result text = %q, want to contain 'hi'", got.Content[0].Text)
}
}
func TestRun_MaxRounds(t *testing.T) {
t.Parallel()
// 每轮使用不同的调用,使关键工具循环安全网不会
// 介入;这里的目标是演练轮次预算。
call1 := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`
call2 := `{"tool_calls":[{"name":"echo","args":{"q":"y"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call1}},
{resp: model.Response{Content: call2}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: "x"})
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 2})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Steps != 2 {
t.Fatalf("Steps = %d, want 2", res.Steps)
}
if res.StopReason != StopReasonMaxRounds {
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonMaxRounds)
}
if res.ToolCallsExecuted != 2 {
t.Fatalf("ToolCallsExecuted = %d, want 2", res.ToolCallsExecuted)
}
}
func TestRun_ToolError_FeedsBack(t *testing.T) {
t.Parallel()
call := `{"tool_calls":[{"name":"boom","args":{}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "recovered"}},
}}
td := dispatcherWith(t, &mockTool{name: "boom", err: errors.New("kaboom")})
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "boom now")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "recovered" {
t.Fatalf("Text = %q, want recovered", res.Text)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
got := ad.reqs[len(ad.reqs)-1].Messages[len(ad.reqs[len(ad.reqs)-1].Messages)-1]
if !strings.Contains(got.Content[0].Text, "kaboom") {
t.Fatalf("tool result should carry the error, got %q", got.Content[0].Text)
}
}
func TestRun_ToolTimeout_FeedsBack(t *testing.T) {
t.Parallel()
call := `{"tool_calls":[{"name":"slow","args":{}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "continued after timeout"}},
}}
td := dispatcherWithTimeout(t, 30*time.Millisecond, &mockTool{name: "slow", delay: 500 * time.Millisecond})
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "slow it")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "continued after timeout" {
t.Fatalf("Text = %q, want recovery text", res.Text)
}
got := ad.reqs[len(ad.reqs)-1].Messages[len(ad.reqs[len(ad.reqs)-1].Messages)-1]
if !strings.Contains(got.Content[0].Text, "timed out") {
t.Fatalf("tool result should report the timeout, got %q", got.Content[0].Text)
}
}
func TestRun_CheckpointPerStep(t *testing.T) {
t.Parallel()
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "done"}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
a := newTestAgent(t, ad, td)
cp := newTestCheckpointStore(t)
l := New(Options{Agent: a, Checkpoint: cp, MaxRounds: 8})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
t.Fatalf("Run: %v", err)
}
got, err := cp.List(context.Background(), "sess")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(got))
}
if got[0].Step != 1 || got[1].Step != 2 {
t.Fatalf("checkpoint steps = [%d,%d], want [1,2]", got[0].Step, got[1].Step)
}
}
func TestRun_TraceSpans(t *testing.T) {
t.Parallel()
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "done"}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
a := newTestAgent(t, ad, td)
var buf bytes.Buffer
tr := trace.New(trace.NewJSONRecorder(&buf))
l := New(Options{Agent: a, Tracer: tr, MaxRounds: 8})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
t.Fatalf("Run: %v", err)
}
saw := map[trace.SpanKind]bool{}
for _, r := range decodeSpanLines(t, buf.Bytes()) {
saw[r.Kind] = true
}
for _, k := range []trace.SpanKind{trace.SpanKindLLM, trace.SpanKindTool, trace.SpanKindStateTransition} {
if !saw[k] {
t.Fatalf("missing span kind %q in %+v", k, saw)
}
}
}
func TestRun_MemoryInject(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
if _, err := store.Add(context.Background(), memory.MemoryItem{
PersonaID: "persona-a", Content: "她喜欢喝冰美式",
Scope: memory.ScopePersona, Kind: memory.KindRelationship,
}); err != nil {
t.Fatalf("seed memory: %v", err)
}
ret := memory.NewRetriever(store)
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "喜欢冰美式"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, Retriever: ret, PersonaID: "persona-a", MaxRounds: 8})
if _, err := l.Run(context.Background(), "sess", nil); err != nil {
t.Fatalf("Run: %v", err)
}
// 空对话仍会走 injectMemory;查询为空时 retriever
// 按最近程度返回该角色的条目。
if len(ad.reqs) == 0 {
t.Fatal("no request captured")
}
found := false
for _, m := range ad.reqs[0].Messages {
if m.Role == model.RoleSystem && strings.Contains(m.Content[0].Text, "冰美式") {
found = true
break
}
}
if !found {
t.Fatalf("injected memory not found in request messages: %+v", ad.reqs[0].Messages)
}
}
func TestRun_MemoryExtract(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
ext := memory.NewManualExtractor("persona-a", "role-1")
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "记住了"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, Memory: store, Extractor: ext, PersonaID: "persona-a", MaxRounds: 8})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}); err != nil {
t.Fatalf("Run: %v", err)
}
items, err := store.ListByPersona(context.Background(), "persona-a")
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
if items[0].Content != "我爱喝冰美式" {
t.Fatalf("item content = %q, want 我爱喝冰美式", items[0].Content)
}
}
func TestRun_StreamDeltas(t *testing.T) {
t.Parallel()
ad := &mockAdapter{
stream: []mgResp{{resp: model.Response{Content: "Hello world"}}},
chunks: [][]string{{"Hel", "lo ", "world"}},
}
a := newTestAgent(t, ad, nil)
var mu sync.Mutex
var deltas []string
l := New(Options{
Agent: a,
OnStream: func(ev model.StreamEvent) { mu.Lock(); deltas = append(deltas, ev.Delta); mu.Unlock() },
MaxRounds: 8,
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "Hello world" {
t.Fatalf("Text = %q, want Hello world", res.Text)
}
mu.Lock()
got := append([]string(nil), deltas...)
mu.Unlock()
if len(got) != 3 || got[0] != "Hel" || got[1] != "lo " || got[2] != "world" {
t.Fatalf("deltas = %+v, want [Hel lo world]", got)
}
}
func TestRun_StreamGuardrailBlocks(t *testing.T) {
t.Parallel()
g := &mockGuard{inputVerdict: &guardrail.Verdict{Allow: false, Reason: "blocked in"}}
pipe := guardrail.New(guardrail.WithGuard(g))
ad := &mockAdapter{}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, Guards: pipe, OnStream: func(model.StreamEvent) {}, MaxRounds: 8})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("StopReason = %q, want %q", res.StopReason, agent.StopReasonGuardrailInput)
}
if res.Text != "blocked in" {
t.Fatalf("Text = %q, want blocked in", res.Text)
}
}
func TestRun_ContextCanceled(t *testing.T) {
t.Parallel()
ad := &mockAdapter{}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, MaxRounds: 8})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "hi")}); !errors.Is(err, context.Canceled) {
t.Fatalf("Run error = %v, want context.Canceled", err)
}
}
func TestRun_NilAgent(t *testing.T) {
t.Parallel()
l := New(Options{})
if _, err := l.Run(context.Background(), "sess", nil); !errors.Is(err, ErrNilAgent) {
t.Fatalf("Run error = %v, want ErrNilAgent", err)
}
}
func TestNew_DefaultMaxRounds(t *testing.T) {
t.Parallel()
if got := New(Options{}).MaxRounds(); got != DefaultMaxRounds {
t.Fatalf("MaxRounds = %d, want %d", got, DefaultMaxRounds)
}
if got := New(Options{MaxRounds: 3}).MaxRounds(); got != 3 {
t.Fatalf("MaxRounds = %d, want 3", got)
}
}
// decodeSpanLines 把作为 JSONL 发出的每个 JSON span 读入 recs,
// 任何一行格式错误都让测试失败。
func decodeSpanLines(t *testing.T, data []byte) []trace.SpanRecord {
t.Helper()
sc := bufio.NewScanner(bytes.NewReader(data))
var recs []trace.SpanRecord
for sc.Scan() {
line := sc.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var r trace.SpanRecord
if err := json.Unmarshal(line, &r); err != nil {
t.Fatalf("decode span: %v", err)
}
recs = append(recs, r)
}
if err := sc.Err(); err != nil {
t.Fatalf("scan spans: %v", err)
}
return recs
}