package gateway import ( "context" "errors" "fmt" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" ) // 本文件锁定切片 KB1 的网关侧一半:**回合收尾必须留下一条可见的 assistant 消息** // (见 session_turn_reply.go)。 // // 复现场景逐条照抄安卓侧实测(会话 E2E-RETRY,纯文本回合):一条短文本 → 模型连续 // 两次调用一个失败的 knowledge 工具 → 之后给出一段空回复 → 回合「正常」收尾。 // 修复前的结果是历史里只有那条 user 消息,客户端两张「工具调用失败」卡片之后直接 // 回空闲(没有回复、也没有错误横幅)。 // // 断言走真路径:真 loop(真模型步、真工具批次)+ 真 knowledge 工具(真 retriever) // + 真 SQLite history + 真编排器。只有模型适配器是脚本化的。 // kbScriptAdapter 是脚本化的流式适配器:前 toolSteps 步请求一次 knowledge 工具调用 // (参数默认**按步变化**,因此绝不会命中循环自身的「同调用去重」安全网,模型真的连撞 // 同一堵墙),之后给出 final 正文(空串 = 复现里的空回复)。 type kbScriptAdapter struct { mu sync.Mutex steps int toolSteps int final string // errAt 在指定步(1 起)返回模型错误;0 表示不报错。 errAt int // identicalArgs 让每一步请求**完全相同**的调用(参数与步号无关),用来触发 // 循环的关键工具循环安全网。 identicalArgs bool reqs []model.Request } func (a *kbScriptAdapter) Name() string { return "kb1-script" } func (a *kbScriptAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) { return a.ChatStream(ctx, req, nil) } func (a *kbScriptAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { a.mu.Lock() defer a.mu.Unlock() a.steps++ step := a.steps a.reqs = append(a.reqs, req) if a.errAt != 0 && step == a.errAt { return model.Response{}, errors.New("model: upstream 502") } if step <= a.toolSteps { query := fmt.Sprintf("q-%d", step) if a.identicalArgs { query = "q-same" } return model.Response{ Content: fmt.Sprintf(`{"tool_calls":[{"name":"knowledge","args":{"query":%q}}]}`, query), }, nil } return model.Response{Content: a.final}, nil } func (a *kbScriptAdapter) stepCount() int { a.mu.Lock() defer a.mu.Unlock() return a.steps } // kbSawToolFailure 报告适配器的某个请求窗口里是否带上了该工具的**失败结果** // (RoleTool 消息里的 "tool failed"):工具失败必须回灌给模型,吞掉它 // 就等于让模型在错误的假设下继续(切片 KB1 的「不许静默」)。 func kbSawToolFailure(a *kbScriptAdapter, name string) bool { a.mu.Lock() defer a.mu.Unlock() for _, req := range a.reqs { for _, m := range req.Messages { if m.Role == model.RoleTool && strings.Contains(messageText(m), "tool "+name+" failed") { return true } } } return false } // kbFailingRetriever 让 knowledge 工具的检索每次都以执行错误失败 —— 形状与生产里 // 「RAG / 向量后端不可用」一致,也就是客户端上那张「工具调用失败 · knowledge」卡片。 type kbFailingRetriever struct { mu sync.Mutex calls int lastPersona string } func (r *kbFailingRetriever) Retrieve(_ context.Context, personaID, query string, topK int) ([]memory.Result, error) { r.mu.Lock() defer r.mu.Unlock() r.calls++ r.lastPersona = personaID return nil, fmt.Errorf("memory: retrieve list: backend unavailable (query=%s, topK=%d)", query, topK) } func (r *kbFailingRetriever) callCount() int { r.mu.Lock() defer r.mu.Unlock() return r.calls } // kbTurnFixture 装配一条端到端回合链路的全部生产依赖。 type kbTurnFixture struct { store *history.Store ad *kbScriptAdapter ret *kbFailingRetriever orch *Orchestrator mu sync.Mutex persisted []history.Message } // newKBTurnFixture 装配真 loop(含真 io 工具集与真知识库检索器)+ 真 SQLite history // + 真编排器。maxRounds 取 0 时用循环默认值(8 轮)。 func newKBTurnFixture(t *testing.T, ad *kbScriptAdapter, maxRounds int) *kbTurnFixture { t.Helper() store := newHistoryStoreForGateway(t) ret := &kbFailingRetriever{} ioSet, err := iotools.New(&config.Config{}, iotools.WithKnowledgeRetriever(ret), iotools.WithLogger(gwTestLogger())) if err != nil { t.Fatalf("iotools.New: %v", err) } dispatcher := newSteerDispatcher(t, ioSet...) ag, err := agent.New("你是测试助手。", dispatcher, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } // OnStream 非 nil ⇒ 走生产所用的流式步路径(安卓侧实测的路径),它正是 // 「模型返回空正文 ⇒ 回合按最终答复落定」的那一条。PersonaID 是生产里必有的 // 默认角色(cmd/cyrene 的 a.defaultPersonaID()):工具据此拿到角色作用域, // 而不是撞回 memory 的 missing persona。 l := loop.New(loop.Options{ Agent: ag, Tools: dispatcher, MaxRounds: maxRounds, PersonaID: kbPersonaID, OnStream: func(model.StreamEvent) {}, Log: gwTestLogger(), }) f := &kbTurnFixture{store: store, ad: ad, ret: ret} f.orch = NewOrchestrator(OrchestratorOptions{ Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) { return l.Run(loop.WithSessionEnv(ctx, env), sessionID, messages) }, Log: gwTestLogger(), History: store, OnPersisted: func(m history.Message) { f.mu.Lock() f.persisted = append(f.persisted, m) f.mu.Unlock() }, }) return f } // submit 把一个纯文本回合交给编排器并跑完(REST 同步路径,确定性)。 func (f *kbTurnFixture) submit(t *testing.T) (*loop.Result, error) { t.Helper() sr, res := f.orch.SubmitSync(context.Background(), kbSessionID, []model.Message{userMsg("W15-key-test")}, loop.OwnerEnv()) if !sr.Accepted || !sr.Immediate { t.Fatalf("submit = %+v, want Accepted+Immediate", sr) } return res, sr.Err } // kbSessionID 是本文件的会话 id。 const kbSessionID = "sess-kb1" // kbPersonaID 是测试 loop 配置的默认角色(生产里由组合根注入)。 const kbPersonaID = "cyrene" // assertUserAndSingleAssistant 断言历史里恰好一条 user + 恰好一条 assistant, // 并返回那条 assistant 消息。 func (f *kbTurnFixture) assertUserAndSingleAssistant(t *testing.T) history.Message { t.Helper() users := countHistoryRows(t, f.store, kbSessionID, history.RoleUser) assistants := countHistoryRows(t, f.store, kbSessionID, history.RoleAssistant) if len(users) != 1 { t.Fatalf("user 消息 = %d 条, want 1", len(users)) } if len(assistants) != 1 { t.Fatalf("assistant 消息 = %d 条, want 恰好 1(回合收尾必须留下可见回复)", len(assistants)) } return assistants[0] } // TestTurnEnd_EmptyReplyAfterFailedKnowledgeStillPersistsAssistant 是必测①: // 复现安卓侧的场景 —— 模型连续两次调用失败的 knowledge 工具,随后给出空回复 —— // 断言 user 消息 + **恰好一条** assistant 消息落库,且内容是看得懂的诚实说明。 func TestTurnEnd_EmptyReplyAfterFailedKnowledgeStillPersistsAssistant(t *testing.T) { t.Parallel() ad := &kbScriptAdapter{toolSteps: 2, final: ""} f := newKBTurnFixture(t, ad, 0) res, err := f.submit(t) if err != nil { t.Fatalf("回合不该报错(它按「正常收尾」落定): %v", err) } if res.Text == "" { t.Fatal("Result.Text 为空 ⇒ REST reply / 续跑尾部都会是空的") } assistant := f.assertUserAndSingleAssistant(t) if strings.TrimSpace(assistant.Content) == "" { t.Fatal("落库的 assistant 内容为空 —— 客户端会看到「什么都没发生」") } if assistant.Content != res.Text { t.Fatalf("落库内容 %q != Result.Text %q", assistant.Content, res.Text) } // 两次工具失败确实发生过(工具真的被执行了两次,而不是被去重跳过)。 if got := f.ret.callCount(); got != 2 { t.Fatalf("knowledge 工具执行次数 = %d, want 2(复现里正是两次失败)", got) } // 角色经 loop 发布给工具:绝不把空角色透给记忆检索器(根因①)。 if f.ret.lastPersona == "" { t.Fatal("工具收到的角色为空 —— 又会撞回 memory 的 missing persona") } // 失败**可见且可消化**:模型在下一次调用的窗口里看到了结构化失败结果, // 而不是被静默吞掉(软判/拦截路径同理,见 writeToolOutcome)。 if !kbSawToolFailure(ad, "knowledge") { t.Fatal("模型没有看到 knowledge 的失败结果 —— 失败被吞掉了") } // 跨端同步(message_persisted)同样带上这条消息,客户端无需刷新即可看到。 f.mu.Lock() persisted := append([]history.Message(nil), f.persisted...) f.mu.Unlock() if len(persisted) != 2 || persisted[1].Role != history.RoleAssistant { t.Fatalf("persisted = %+v, want [user assistant]", persisted) } } // TestTurnEnd_MaxRoundsExhaustedStillPersistsAssistant 是必测④:工具循环耗尽 // 8 轮上限时的收尾也落一条可见消息(loop 在该出口返回的空 Text 曾同样静默)。 func TestTurnEnd_MaxRoundsExhaustedStillPersistsAssistant(t *testing.T) { t.Parallel() ad := &kbScriptAdapter{toolSteps: 1 << 10, final: ""} // 永远请求工具,永不收尾 f := newKBTurnFixture(t, ad, loop.DefaultMaxRounds) res, err := f.submit(t) if err != nil { t.Fatalf("轮次用尽不该报错: %v", err) } if res.StopReason != loop.StopReasonMaxRounds { t.Fatalf("StopReason = %q, want %q", res.StopReason, loop.StopReasonMaxRounds) } if ad.stepCount() != loop.DefaultMaxRounds { t.Fatalf("模型步数 = %d, want %d", ad.stepCount(), loop.DefaultMaxRounds) } assistant := f.assertUserAndSingleAssistant(t) if !strings.Contains(assistant.Content, "轮次") { t.Fatalf("轮次用尽的说明文案不对: %q", assistant.Content) } } // TestTurnEnd_ToolLoopTerminatedTextIsHonestChinese 断言关键工具循环安全网终止的 // 回合落的是**中文诚实说明**,而不是循环内部那句英文诊断文案(同一调用跨轮重复 // 三次即触发,正是「连撞两次同墙」的下一个回合)。 func TestTurnEnd_ToolLoopTerminatedTextIsHonestChinese(t *testing.T) { t.Parallel() ad := &kbScriptAdapter{toolSteps: 1 << 10, identicalArgs: true} f := newKBTurnFixture(t, ad, 0) res, err := f.submit(t) if err != nil { t.Fatalf("安全网终止不该报错: %v", err) } if res.StopReason != loop.StopReasonToolLoopTerminated { t.Fatalf("StopReason = %q, want %q", res.StopReason, loop.StopReasonToolLoopTerminated) } assistant := f.assertUserAndSingleAssistant(t) if !strings.Contains(assistant.Content, "工具调用") { t.Fatalf("落库内容应是中文诚实说明: %q", assistant.Content) } if strings.Contains(assistant.Content, "critical tool-loop") { t.Fatalf("内部英文诊断文案漏给了用户: %q", assistant.Content) } } // TestTurnEnd_ModelErrorStillPersistsAssistant 断言回合因内部错误中断(Run 返回 // nil Result + error)时,历史里仍留下一条诚实说明 —— 另一条曾经静默的出口。 func TestTurnEnd_ModelErrorStillPersistsAssistant(t *testing.T) { t.Parallel() ad := &kbScriptAdapter{errAt: 1} f := newKBTurnFixture(t, ad, 0) _, err := f.submit(t) if err == nil { t.Fatal("模型报错的回合应当把错误交回调用方") } assistant := f.assertUserAndSingleAssistant(t) if !strings.Contains(assistant.Content, "出了点问题") { t.Fatalf("内部错误的说明文案不对: %q", assistant.Content) } } // TestTurnEnd_NormalReplyIsPersistedVerbatim 是必测③的回归项:正常回合(无工具 // 失败)的落库行为逐字不变 —— 恰好一条 assistant,内容与模型正文完全相同。 // // 样本取**已规范**的正文:写入侧会给未被

包裹的段落补上包裹(幂等的加法, // 见 session_turn_reply_paragraph_test.go),因此「逐字不变」的守门断言要用规范 // 形态来钉 —— 它保证规整绝不二次加工好文本。 func TestTurnEnd_NormalReplyIsPersistedVerbatim(t *testing.T) { t.Parallel() ad := &kbScriptAdapter{toolSteps: 0, final: "

你好,我在。

"} f := newKBTurnFixture(t, ad, 0) res, err := f.submit(t) if err != nil { t.Fatalf("正常回合不该报错: %v", err) } if res.Text != "

你好,我在。

" { t.Fatalf("Result.Text = %q", res.Text) } assistant := f.assertUserAndSingleAssistant(t) if assistant.Content != "

你好,我在。

" { t.Fatalf("落库内容 = %q, want 逐字等于模型正文", assistant.Content) } } // TestTurnEnd_InterruptedTurnStaysSilent 是必测③的边界项:被 interrupt 取消的 // 回合**不**补任何消息(用户意图就是停下)。这里用 current 口径,它刻意不递增 // 代次,因此「不落库」只可能来自取消判定本身。 func TestTurnEnd_InterruptedTurnStaysSilent(t *testing.T) { t.Parallel() store := newHistoryStoreForGateway(t) runner := newKBBlockingRunner() o := NewOrchestrator(OrchestratorOptions{Run: runner.Run, Log: gwTestLogger(), History: store}) sr := o.Submit(context.Background(), kbSessionID, []model.Message{userMsg("停下")}, loop.OwnerEnv()) if !sr.Immediate { t.Fatalf("submit = %+v", sr) } runner.waitEntered(t) if dropped := o.InterruptScope(kbSessionID, InterruptScopeCurrent); dropped != 0 { t.Fatalf("current 口径不该丢弃待处理:%d", dropped) } runner.release() if !waitIdle(o, kbSessionID) { t.Fatal("会话没有回到空闲") } if n := len(countHistoryRows(t, store, kbSessionID, history.RoleAssistant)); n != 0 { t.Fatalf("被取消的回合落了 %d 条 assistant 消息, want 0", n) } if n := len(countHistoryRows(t, store, kbSessionID, history.RoleUser)); n != 1 { t.Fatalf("user 消息 = %d 条, want 1(取消不影响已落库的用户消息)", n) } } // kbBlockingRunner 是一个可取消的 TurnRunner:它阻塞到被释放,然后按 ctx 的取消 // 状态返回 —— 模拟被 interrupt 打断的回合。 type kbBlockingRunner struct { entered chan struct{} gate chan struct{} once sync.Once } func newKBBlockingRunner() *kbBlockingRunner { return &kbBlockingRunner{entered: make(chan struct{}), gate: make(chan struct{})} } func (r *kbBlockingRunner) Run(ctx context.Context, _ string, _ []model.Message, _ loop.SessionEnv) (*loop.Result, error) { r.once.Do(func() { close(r.entered) }) select { case <-r.gate: case <-ctx.Done(): } if err := ctx.Err(); err != nil { return nil, fmt.Errorf("loop: run: %w", err) } return &loop.Result{Text: "放行后的回复", StopReason: "stop"}, nil } func (r *kbBlockingRunner) waitEntered(t *testing.T) { t.Helper() select { case <-r.entered: case <-time.After(kbRunnerTimeout): t.Fatal("回合没有进入 runner") } } func (r *kbBlockingRunner) release() { close(r.gate) } // kbRunnerTimeout 是后台回合进入 runner 的等待上限(真 loop 的首步可能要先 // 组装上下文,给足余量但绝不让测试无限挂住)。 const kbRunnerTimeout = 3 * time.Second // TestTurnEnd_StaleGenerationStaysSilent 是必测③的另一条边界:被更新的回合取代的 // 陈旧代次**不**补消息(那次收尾不属于当前会话状态)。这里让回合在代次前进之后才 // 返回一个空 Result —— 若补消息逻辑漏了代次判定,就会多出一条 assistant。 func TestTurnEnd_StaleGenerationStaysSilent(t *testing.T) { t.Parallel() store := newHistoryStoreForGateway(t) runner := newKBStaleRunner() o := NewOrchestrator(OrchestratorOptions{Run: runner.Run, Log: gwTestLogger(), History: store}) o.Submit(context.Background(), kbSessionID, []model.Message{userMsg("旧回合")}, loop.OwnerEnv()) runner.waitEntered(t) // all 口径:取消在飞回合并把代次推进(该回合的收尾从此是陈旧代次)。 o.InterruptScope(kbSessionID, InterruptScopeAll) runner.release() if !waitIdle(o, kbSessionID) { t.Fatal("会话没有回到空闲") } if n := len(countHistoryRows(t, store, kbSessionID, history.RoleAssistant)); n != 0 { t.Fatalf("陈旧代次的回合落了 %d 条 assistant 消息, want 0", n) } } // kbStaleRunner 返回一个**空 Result 且不报错**的回合:它给不出可见文本,但代次已经 // 前进,因此不该被补上任何消息。 type kbStaleRunner struct { entered chan struct{} gate chan struct{} once sync.Once } func newKBStaleRunner() *kbStaleRunner { return &kbStaleRunner{entered: make(chan struct{}), gate: make(chan struct{})} } func (r *kbStaleRunner) Run(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) { r.once.Do(func() { close(r.entered) }) <-r.gate return &loop.Result{Text: "", StopReason: agent.StopReasonStop}, nil } func (r *kbStaleRunner) waitEntered(t *testing.T) { t.Helper() select { case <-r.entered: case <-time.After(kbRunnerTimeout): t.Fatal("回合没有进入 runner") } } func (r *kbStaleRunner) release() { close(r.gate) }