Files
Cyrene_v2/internal/runtime/loop/loop_test.go
T
AskaEth 6e7ec56f9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 事件批 — tool_execution 三事件 + streamId + turn.start/end(裁决 01.4.1/01.4.2/01.4.3/01.4.5/03.4.4/1.12)
执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。

- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
  `tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
  `StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
  **Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
  ⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
  无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
  span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
  `AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
  `stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
  把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
  `streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
  (最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
  不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
  与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。

验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。

未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
2026-10-04 11:26:35 +08:00

386 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) {
if ev.Kind != model.StreamKindTextDelta {
return
}
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
}