package loop import ( "context" "encoding/json" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本文件锁住裁决 13.4.5 的**目标态**在 loop 侧的三条不变量(design/15 §2.0/§2.4): // // I1 同会话在途回合数恒为 1:忙时新消息在**下一个回合内边界**被吃进**同一回合** // (模型在该步看到的窗口里有它),循环绝不因此新开第二个回合; // I2 恰好被处理一次:注入只并进本轮视图,不写回运行窗口 —— checkpoint 快照与 // 记忆提取面因此看不到它,它也不会被当成新输入再答一遍; // I3 转向不取消已发起的工具:注入点只在**一批工具调用结束之后**、下一次模型调用 // 之前;正在执行的工具跑完,其结果仍在窗口里。 // // 这些测试用真的 mockAdapter/mockTool 装配真循环,断言的是**模型实际收到的消息** // 与**真的落下的 checkpoint/提取面**,不是内部字段的回读。 // steerTrace 是工具与转向源共享的**加锁**事件序:I3 要证明的「工具跑完 → 边界注入」 // 这个先后关系,在 Windows 上无法用 time.Now() 证明(粒度粗到两者拿到同一时间戳), // 只能靠共享的单调事件序;加锁是为了让 -race 也保持干净。 type steerTrace struct { mu sync.Mutex events []string } func (t *steerTrace) add(ev string) { if t == nil { return } t.mu.Lock() t.events = append(t.events, ev) t.mu.Unlock() } func (t *steerTrace) snapshot() []string { if t == nil { return nil } t.mu.Lock() defer t.mu.Unlock() return append([]string(nil), t.events...) } // steerScript 是一份脚本化转向源:按 step 返回要注入的消息,并记录被问到的边界。 type steerScript struct { mu sync.Mutex at map[int][]model.Message steps []int returned [][]model.Message // trace 非 nil 时把「真的注入了」记进共享事件序。 trace *steerTrace } func newSteerScript(at map[int][]model.Message) *steerScript { return &steerScript{at: at} } // withTrace 让本转向源把「真的注入了」记进共享事件序。 func (s *steerScript) withTrace(tr *steerTrace) *steerScript { s.trace = tr return s } func (s *steerScript) Steer(_ context.Context, req SteerRequest) []model.Message { s.mu.Lock() defer s.mu.Unlock() s.steps = append(s.steps, req.Step) msgs := s.at[req.Step] if len(msgs) == 0 { return nil } s.returned = append(s.returned, msgs) s.trace.add("steer:" + messageText(msgs[0])) return msgs } // askedSteps 返回转向源被问到的边界序号序列。 func (s *steerScript) askedSteps() []int { s.mu.Lock() defer s.mu.Unlock() return append([]int(nil), s.steps...) } // boundaryTool 是一个记录自己是否完整跑完、以及 ctx 是否在运行中被取消的工具, // 供 I3 断言「注入没有取消已发起的工具」。 type boundaryTool struct { mu sync.Mutex calls int // canceled 记录这次调用是否因 ctx 在运行中被取消而中断。 canceled bool delay time.Duration // trace 非 nil 时在完整跑完之际记下 "tool:done"。 trace *steerTrace } func (b *boundaryTool) Name() string { return "slow" } func (b *boundaryTool) Description() string { return "slow tool for steering tests" } func (b *boundaryTool) Parameters() (map[string]any, error) { return nil, nil } func (b *boundaryTool) Execute(ctx context.Context, _ any) (any, error) { if b.delay > 0 { select { case <-time.After(b.delay): case <-ctx.Done(): b.mu.Lock() b.canceled = true b.mu.Unlock() return nil, ctx.Err() } } b.mu.Lock() defer b.mu.Unlock() b.calls++ b.trace.add("tool:done") return "slow-done", nil } // counts 返回该工具被完整执行的次数与被取消的次数。 func (b *boundaryTool) counts() (calls int, canceled bool) { b.mu.Lock() defer b.mu.Unlock() return b.calls, b.canceled } // steerTestCall 是一次「一步工具 + 一步收尾」的脚本:第 1 步请求工具调用,第 2 步 // 给出最终答复。它是 I1/I3 的公共前置(一个回合至少要有两条模型步,才存在边界)。 const steerTestToolCall = `{"tool_calls":[{"name":"slow","args":{}}]}` // steerTestAdapter 返回一个两步脚本的适配器与它的工具。 func steerTestAdapter(tool *boundaryTool) *mockAdapter { return &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: steerTestToolCall}}, {resp: model.Response{Content: "两步收尾"}}, }} } // TestRun_SteerInjectedAtStepBoundary_SingleTurn 钉住 I1 与 §2.4 的最新性:忙时新消息 // 在**下一个 step 边界**被吃进同一回合 —— 第 2 步的模型窗口里有它(且排在工具结果 // 之后),而整轮只有 2 次模型调用、只有 1 个回合结果。 func TestRun_SteerInjectedAtStepBoundary_SingleTurn(t *testing.T) { t.Parallel() tool := &boundaryTool{} ad := steerTestAdapter(tool) td := dispatcherWith(t, tool) l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8}) src := newSteerScript(map[int][]model.Message{ 2: {model.TextMessage(model.RoleUser, "忙时新消息")}, }) res, err := l.Run(WithSteerSource(WithSessionEnv(context.Background(), envOwner()), src), "sess-steer", []model.Message{model.TextMessage(model.RoleUser, "第一条")}) if err != nil { t.Fatalf("Run: %v", err) } // I1:只有一个回合(Steps=2 的两步同一回合),不是「注入后又开了一回合」。 if res.Steps != 2 || res.StopReason != agent.StopReasonStop { t.Fatalf("result = %+v, want a single 2-step turn ending with stop", res) } if len(ad.reqs) != 2 { t.Fatalf("模型调用 %d 次, want 2(一次工具步 + 一次收尾步;注入绝不新开回合)", len(ad.reqs)) } // 边界:每个 step 组装前问一次,正好 [1 2]。 if got := src.askedSteps(); len(got) != 2 || got[0] != 1 || got[1] != 2 { t.Fatalf("转向边界 = %v, want [1 2]", got) } step2 := ad.reqs[1].Messages last := step2[len(step2)-1] if last.Role != model.RoleUser || messageText(last) != "忙时新消息" { t.Fatalf("第 2 步窗口末条 = %+v, want 注入的忙碌消息(它必须对同一回合的下一个模型步立即可见)", last) } // 旧窗口已产出的内容(工具结果)完好:注入是追加,不是替换。 if !windowContainsRoleText(step2, model.RoleTool, "slow-done") { t.Fatalf("第 2 步窗口丢了工具结果(注入不得顶掉旧窗口内容):%+v", windowTexts(step2)) } // 第一条用户消息仍在窗口里(同一窗口,不是新回合)。 if !windowContainsRoleText(step2, model.RoleUser, "第一条") { t.Fatalf("第 2 步窗口丢了本轮第一条用户消息:%+v", windowTexts(step2)) } } // TestRun_SteerDoesNotCancelInFlightTools 钉住 I3:注入发生在**一批工具调用结束之后** // —— 工具完整跑完(未被取消),其结果在窗口里,且注入**在事件序上晚于**工具的完成 // (Windows 时间戳粒度粗到两者同值,故用共享事件序而不是时间比较)。 func TestRun_SteerDoesNotCancelInFlightTools(t *testing.T) { t.Parallel() tr := &steerTrace{} tool := &boundaryTool{delay: 20 * time.Millisecond, trace: tr} ad := steerTestAdapter(tool) td := dispatcherWith(t, tool) l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8}) src := newSteerScript(map[int][]model.Message{ 2: {model.TextMessage(model.RoleUser, "工具跑着的时候我插一句")}, }).withTrace(tr) res, err := l.Run(WithSteerSource(context.Background(), src), "sess-tools", []model.Message{model.TextMessage(model.RoleUser, "去跑那个慢工具")}) if err != nil { t.Fatalf("Run: %v", err) } calls, canceled := tool.counts() if calls != 1 || canceled { t.Fatalf("工具 calls=%d canceled=%v, want 1/false(转向绝不取消已发起的工具)", calls, canceled) } if res.ToolCallsExecuted != 1 { t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) } // 事件序必须恰好是「工具跑完」→「边界注入」:注入点落在工具批次**之后**。 if got := tr.snapshot(); len(got) != 2 || got[0] != "tool:done" || got[1] != "steer:工具跑着的时候我插一句" { t.Fatalf("事件序 = %v, want [tool:done steer:…](注入必须在工具批次结束之后的边界上)", got) } // 注入只能发生在边界上:整个回合的边界恰好是 [1 2],没有任何「批次中途」的调用。 if got := src.askedSteps(); len(got) != 2 || got[0] != 1 || got[1] != 2 { t.Fatalf("转向边界 = %v, want [1 2](批次中途绝不注入)", got) } } // TestRun_SteerDoesNotPolluteCheckpointOrExtraction 钉住 I2 的另一半:注入只进本轮 // 视图,**不进**该回合的运行窗口 —— checkpoint 快照与后台记忆提取的输入面里都不该 // 出现它,而该回合真正的输入与工具结果必须在。 func TestRun_SteerDoesNotPolluteCheckpointOrExtraction(t *testing.T) { t.Parallel() tool := &boundaryTool{} ad := steerTestAdapter(tool) td := dispatcherWith(t, tool) cp := newTestCheckpointStore(t) extractor := &knowledgeCaptureExtractor{} l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8, Checkpoint: cp, Memory: newTestMemoryStore(t), Extractor: extractor}) const busy = "忙时插进来的一句" src := newSteerScript(map[int][]model.Message{2: {model.TextMessage(model.RoleUser, busy)}}) if _, err := l.Run(WithSteerSource(context.Background(), src), "sess-cp", []model.Message{model.TextMessage(model.RoleUser, "第一条")}); err != nil { t.Fatalf("Run: %v", err) } // 模型确实看到了它(否则这条断言就没有意义)。 if !windowContainsRoleText(ad.reqs[len(ad.reqs)-1].Messages, model.RoleUser, busy) { t.Fatal("测试前提不成立:注入的消息没有出现在模型窗口里") } latest, err := cp.LoadLatest(context.Background(), "sess-cp") if err != nil { t.Fatalf("LoadLatest: %v", err) } var st checkpointState if err := json.Unmarshal(latest.State, &st); err != nil { t.Fatalf("unmarshal checkpoint: %v", err) } if msgInAny(st.Messages, busy) { t.Fatalf("注入的消息进了 checkpoint 快照(只应影响本轮视图):%+v", windowTexts(st.Messages)) } if !windowContainsRoleText(st.Messages, model.RoleTool, "slow-done") { t.Fatalf("快照里丢了工具结果:%+v", windowTexts(st.Messages)) } if len(extractor.saw) == 0 { t.Fatal("测试前提不成立:提取器没有被调用") } if msgInAny(extractor.saw, busy) { t.Fatalf("注入的消息进了记忆提取面(会被写成记忆):%+v", windowTexts(extractor.saw)) } } // TestRun_SteerRestrictedSessionStaysRestricted 是红线回归:群聊/陌生人会话**不因 // 转向注入**而拿到主人身份知识(design/11 §0)—— 注入的只是那条用户消息,人格知识 // 块仍按会话环境门控(主人会话有、受限会话一个字都没有),且注入不改变本轮 env。 func TestRun_SteerRestrictedSessionStaysRestricted(t *testing.T) { t.Parallel() for _, tc := range []struct { name string env SessionEnv wantKnow bool }{ {name: "主人会话", env: envOwner(), wantKnow: true}, {name: "群聊", env: envGroup(), wantKnow: false}, {name: "陌生人私聊", env: envStranger(), wantKnow: false}, } { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() tool := &boundaryTool{} ad := steerTestAdapter(tool) td := dispatcherWith(t, tool) l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8, PersonaKnowledgeFor: gateKnowledge}) src := newSteerScript(map[int][]model.Message{2: {model.TextMessage(model.RoleUser, "群里插一句")}}) ctx := WithSteerSource(WithSessionEnv(context.Background(), tc.env), src) if _, err := l.Run(ctx, "sess-env", []model.Message{model.TextMessage(model.RoleUser, "第一条")}); err != nil { t.Fatalf("Run: %v", err) } step2 := ad.reqs[1].Messages if !windowContainsRoleText(step2, model.RoleUser, "群里插一句") { t.Fatal("测试前提不成立:注入的消息没有出现在模型窗口里") } if got := knowledgeIn(step2); got != tc.wantKnow { t.Fatalf("身份知识块在窗口里 = %v, want %v(注入不得绕过会话门)", got, tc.wantKnow) } if env := SessionEnvFrom(ctx); env != tc.env { t.Fatalf("本轮 env 被注入改变了:%+v, want %+v", env, tc.env) } }) } } // TestRun_SteerAbsent_ViewUnchanged 钉住向后兼容:没有转向源的 Run,模型窗口与接线 // 之前逐条一致(前导系统块 + 对话 + 工具结果),绝无多余消息。 func TestRun_SteerAbsent_ViewUnchanged(t *testing.T) { t.Parallel() tool := &boundaryTool{} ad := steerTestAdapter(tool) td := dispatcherWith(t, tool) l := New(Options{Agent: newTestAgent(t, ad, td), Tools: td, MaxRounds: 8}) if _, err := l.Run(context.Background(), "sess-plain", []model.Message{model.TextMessage(model.RoleUser, "第一条")}); err != nil { t.Fatalf("Run: %v", err) } step2 := ad.reqs[1].Messages if len(step2) != 3 { t.Fatalf("未接线转向源时第 2 步窗口 = %d 条 (%v), want 3(原用户消息 + 助手工具调用轮 + 工具结果)", len(step2), windowTexts(step2)) } if msgInAny(step2, "忙时新消息") { t.Fatalf("未接线却出现了注入内容:%+v", windowTexts(step2)) } } // TestFlowPublisher_MarkInputSteered 钉住 design/15 §5 的 flow 标记:注入时给 input // 节点打 steered:true,且**不新建节点**(图仍是一条主干,与 I1 一致)。 func TestFlowPublisher_MarkInputSteered(t *testing.T) { t.Parallel() var mu sync.Mutex var published []any p := NewFlowPublisher(func(_ string, payload any) { mu.Lock() published = append(published, payload) mu.Unlock() }, nil) p.StartRound("web", "第一条") p.MarkInputSteered("web") mu.Lock() last := published[len(published)-1] mu.Unlock() graph, ok := last.(*trace.FlowGraph) if !ok { t.Fatalf("published payload = %T", last) } if len(graph.Nodes) != 1 { t.Fatalf("节点数 = %d, want 1(标记只并入既有 input 节点)", len(graph.Nodes)) } got := graph.Nodes[0] if got.ID != "input" || got.Props["steered"] != true || got.Label != "第一条" { t.Fatalf("input 节点 = %+v, want 保留标签并带上 steered:true", got) } } // windowTexts 把一份模型窗口渲染为 role:文本 列表,供失败信息定位。 func windowTexts(msgs []model.Message) []string { out := make([]string, 0, len(msgs)) for _, m := range msgs { out = append(out, string(m.Role)+":"+messageText(m)) } return out } // windowContainsRoleText 报告窗口里是否存在该角色、且文本包含 want 的消息。 func windowContainsRoleText(msgs []model.Message, role model.Role, want string) bool { for _, m := range msgs { if m.Role == role && strings.Contains(messageText(m), want) { return true } } return false } // msgInAny 报告任何一条消息的文本里是否含 probe。 func msgInAny(msgs []model.Message, probe string) bool { for _, m := range msgs { if strings.Contains(messageText(m), probe) { return true } } return false }