Files
AskaEth 5de708a560
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,channelbridge,turnreply): p 标签半包写入侧规整(修「整条塌成一段」)
裁定(已发安卓):**能修**,走**写入侧规整** —— 不指望模型输出、不动客户端。
- 新增 internal/runtime/turnreply/paragraph.go 的 NormalizeParagraphs:
  把未被 <p> 包裹的段落补上 <p>(按空行分段;<action>/<narration> 段也包进 <p>,
  与正确形态 <p><action>…</action>…</p> 一致)
- **幂等**守门断言:已完全包裹的文本逐字不变(防二次加工)
- 落点覆盖 Web 与 IM 两条落库路径(gateway/session_turn_reply.go、channelbridge/turnreply.go、
  proactive.go)⇒ 两边共用同一份规整文本 ⇒ **历史一次性变好、两个客户端零改动**、渲染口径不分叉
(测试与变异详见切片报告)
2026-10-05 10:56:39 +08:00

450 lines
17 KiB
Go
Raw Permalink 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 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 <name> 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,内容与模型正文完全相同。
//
// 样本取**已规范**的正文:写入侧会给未被 <p> 包裹的段落补上包裹(幂等的加法,
// 见 session_turn_reply_paragraph_test.go),因此「逐字不变」的守门断言要用规范
// 形态来钉 —— 它保证规整绝不二次加工好文本。
func TestTurnEnd_NormalReplyIsPersistedVerbatim(t *testing.T) {
t.Parallel()
ad := &kbScriptAdapter{toolSteps: 0, final: "<p>你好,我在。</p>"}
f := newKBTurnFixture(t, ad, 0)
res, err := f.submit(t)
if err != nil {
t.Fatalf("正常回合不该报错: %v", err)
}
if res.Text != "<p>你好,我在。</p>" {
t.Fatalf("Result.Text = %q", res.Text)
}
assistant := f.assertUserAndSingleAssistant(t)
if assistant.Content != "<p>你好,我在。</p>" {
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) }