Files
Cyrene_v2/internal/services/gateway/session_parallel_frames_test.go
T
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

293 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 gateway
import (
"context"
"log/slog"
"slices"
"testing"
"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/model"
)
// 本文件是 design/15 §2.5.9「帧层」的回归:`turn_begin` 的发射(每次启动一路
// 恰好一帧,含续跑路)与 `steer_ack` 的帧壳 turnId。夹具复用
// session_parallel_test.go(同包)的可逐路放行 runner 与记录型状态汇聚点,因此
// 「哪一帧属于哪一路」是被观测到的事实,而不是靠睡眠赌出来的。
//
// WS 层的对应断言(真连接上的 turn_begin 角色门、interrupt{turnId} 的端到端
// 行为)在 session_parallel_turn_interrupt_test.go。
// transcriptHistory 是 fakeHistory 的转录可读变体:`Recent` 返回已落库的行。
// 裸 fakeHistory 的 Recent 恒为空(那是 TestSteer_UnpersistedPendingStaysPending
// 刻意依赖的行为,不能改),而 steer 边界的匹配**必须**读到转录行 —— 因此本
// 文件用嵌入的方式只覆盖这一个方法。
type transcriptHistory struct{ *fakeHistory }
// Recent 返回该会话最近 n 条已落库行(最旧优先),与 *history.Store 的口径一致。
func (h *transcriptHistory) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
rows := h.records()
out := make([]history.Message, 0, len(rows))
for _, m := range rows {
if m.SessionID == sessionID {
out = append(out, m)
}
}
if len(out) > n {
out = out[len(out)-n:]
}
return out, nil
}
// frameMessageIDs 取出一帧 `turn_begin` 的 messageIds(缺席/形状不符时返回 nil)。
func frameMessageIDs(rec turnStateRec) []string {
ids, _ := rec.data["messageIds"].([]string)
return ids
}
// dataKeys 返回帧载荷的键(升序),用于「键集恰好是这几项」的断言 —— 多一个键
// 就是契约变更,少一个键客户端就配不上。
func dataKeys(data map[string]any) []string {
out := make([]string, 0, len(data))
for k := range data {
out = append(out, k)
}
slices.Sort(out)
return out
}
// assertTurnBeginFrame 逐项断言一帧 `turn_begin`:帧壳 turnId 是该路的权威回合
// 标识,载荷键集与取值都按规范草案 §2.4 钉死。
func assertTurnBeginFrame(t *testing.T, what string, rec turnStateRec, kind string, leg, activeTurns int, turnID string, messageIDs []string) {
t.Helper()
if rec.sessionID != "web" {
t.Fatalf("%s: 帧壳 sessionId = %q, want web", what, rec.sessionID)
}
if rec.typ != EventTurnBegin {
t.Fatalf("%s: 帧类型 = %q, want %q", what, rec.typ, EventTurnBegin)
}
if rec.turnID != turnID {
t.Fatalf("%s: 帧壳 turnId = %q, want %q(必须与该路的权威 turnId 同值,否则客户端两级分桶立即失配)",
what, rec.turnID, turnID)
}
wantKeys := []string{"activeTurns", "kind", "leg", "messageIds"}
if got := dataKeys(rec.data); !slices.Equal(got, wantKeys) {
t.Fatalf("%s: 载荷键集 = %v, want %v", what, got, wantKeys)
}
if rec.data["kind"] != kind {
t.Fatalf("%s: kind = %v, want %q", what, rec.data["kind"], kind)
}
if got := stateInt(rec, "leg"); got != leg {
t.Fatalf("%s: leg = %d, want %d", what, got, leg)
}
if got := stateInt(rec, "activeTurns"); got != activeTurns {
t.Fatalf("%s: activeTurns = %d, want %d", what, got, activeTurns)
}
if got := frameMessageIDs(rec); !slices.Equal(got, messageIDs) {
t.Fatalf("%s: messageIds = %v, want %v(客户端据此把这些气泡置为灰态)", what, got, messageIDs)
}
}
// ---------------------------------------------------------------------------
// ① / 续跑:每次启动一路恰好一帧 turn_begin
// ---------------------------------------------------------------------------
// TestTurnBeginEmittedOncePerLegStarted 钉住①与续跑路:空闲启动的第一路发
// `turn_begin{kind:"turn", leg:1, activeTurns:1}` 且 messageIds 恰好是那条用户
// 消息的**权威 id**;队首续跑同样发一帧(kind 仍是 "turn",但它是一条**新路**,
// turnId 与第一路不同)。两帧的帧壳 turnId 都必须等于交给 runner 的那个 id。
func TestTurnBeginEmittedOncePerLegStarted(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
if !first.Immediate || first.TurnID == "" {
t.Fatalf("第一条 = %+v, want Immediate 且带 turnId", first)
}
calls := f.run.waitStarted(1)
frames := f.sink.ofType(EventTurnBegin)
if len(frames) != 1 {
t.Fatalf("第一路的 turn_begin 帧数 = %d, want 1(恰好一次)", len(frames))
}
assertTurnBeginFrame(t, "第一路", frames[0], turnBeginKindTurn, 1, 1, first.TurnID, []string{"id-1"})
if got := loop.TurnIDFrom(calls[0].ctx); got != frames[0].turnID {
t.Fatalf("交给 runner 的 turnId = %q,turn_begin 帧壳是 %q:两处必须同值", got, frames[0].turnID)
}
// 忙时第二条(未声明并行)⇒ 入队;它的落库 id 就是续跑路的 messageIds。
second := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Pending || second.QueueDepth != 1 {
t.Fatalf("第二条 = %+v, want Pending(1)", second)
}
f.run.release(0)
calls = f.run.waitStarted(2)
frames = f.sink.ofType(EventTurnBegin)
if len(frames) != 2 {
t.Fatalf("turn_begin 帧数 = %d, want 2(续跑也是一条新路,必须有自己的帧)", len(frames))
}
resumeTurnID := loop.TurnIDFrom(calls[1].ctx)
if resumeTurnID == first.TurnID {
t.Fatal("续跑路必须是新路:turnId 不能与上一路相同")
}
assertTurnBeginFrame(t, "续跑路", frames[1], turnBeginKindTurn, 2, 1, resumeTurnID, []string{"id-2"})
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
if got := len(f.sink.ofType(EventTurnBegin)); got != 2 {
t.Fatalf("收尾后的 turn_begin 帧数 = %d, want 2(收尾绝不再补发一帧)", got)
}
}
// ---------------------------------------------------------------------------
// ② 并行路:第二路有自己的 turn_begin(kind:"parallel")
// ---------------------------------------------------------------------------
// TestTurnBeginSecondLegIsParallelKind 钉住②:显式声明并行且照准的第二路发
// `turn_begin{kind:"parallel", leg:2, activeTurns:2}`,且它的 messageIds **只是**
// 它自己那条消息 —— 两帧各钉各的消息,客户端因此能把两条回复分到两个气泡。
func TestTurnBeginSecondLegIsParallelKind(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
f.run.waitStarted(1)
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Immediate || !second.Parallel {
t.Fatalf("显式并行提交 = %+v, want Immediate 且 Parallel", second)
}
calls := f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
frames := f.sink.ofType(EventTurnBegin)
if len(frames) != 2 {
t.Fatalf("turn_begin 帧数 = %d, want 2(两条路各一帧)", len(frames))
}
assertTurnBeginFrame(t, "第一路", frames[0], turnBeginKindTurn, 1, 1, first.TurnID, []string{"id-1"})
assertTurnBeginFrame(t, "第二路", frames[1], turnBeginKindParallel, 2, 2, second.TurnID, []string{"id-2"})
if got := loop.TurnIDFrom(calls[1].ctx); got != frames[1].turnID {
t.Fatalf("第二路交给 runner 的 turnId = %q,帧壳是 %q:两处必须同值", got, frames[1].turnID)
}
f.run.release(1)
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
}
// ---------------------------------------------------------------------------
// ③ steer_ack 的帧壳 turnId
// ---------------------------------------------------------------------------
// TestSteerAckFrameCarriesLegTurnID 钉住③:被转向吃进的那条消息,其
// `steer_ack` **帧壳**必须带吃进它的那一路的 turnId(真并行下客户端靠它把灰态
// 配到正确那一路的 `turn.end`)。这里直接用该路挂在 ctx 上的转向源驱动边界 ——
// 与 loop 在回合内边界调用的是同一个对象。
func TestSteerAckFrameCarriesLegTurnID(t *testing.T) {
t.Parallel()
run := newGatedLegRunner()
sink := &turnSink{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
EmitTurn: sink.EmitTurn,
Log: slog.New(slog.NewTextHandler(discardWriter{}, nil)),
MaxPending: 5,
MaxTail: 20,
History: &transcriptHistory{fakeHistory: &fakeHistory{}},
MaxParallelTurns: 2,
})
first := o.Submit(context.Background(), "web", []model.Message{userMsg("第一条")}, loop.OwnerEnv())
if !first.Immediate {
t.Fatalf("第一条 = %+v, want Immediate", first)
}
calls := run.waitStarted(1)
if sr := o.Submit(context.Background(), "web", []model.Message{userMsg("忙时新消息")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("忙时第二条 = %+v, want Pending", sr)
}
src := loop.SteerSourceFrom(calls[0].ctx)
if src == nil {
t.Fatal("本路的 ctx 上没有挂转向源")
}
steered := src.Steer(calls[0].ctx, loop.SteerRequest{SessionID: "web", Step: 2})
if len(steered) != 1 || messageText(steered[0]) != "忙时新消息" {
t.Fatalf("注入 = %v, want [忙时新消息]", windowText(steered))
}
acks := sink.ofType(wsSteerAck)
if len(acks) != 1 {
t.Fatalf("steer_ack 帧数 = %d, want 1", len(acks))
}
if acks[0].turnID != first.TurnID {
t.Fatalf("steer_ack 帧壳 turnId = %q, want %q(= 吃进它的那一路的 turnId)", acks[0].turnID, first.TurnID)
}
if acks[0].data["messageId"] != "id-2" || stateInt(acks[0], "atStep") != 2 {
t.Fatalf("steer_ack 载荷 = %v, want {messageId:id-2, atStep:2}", acks[0].data)
}
run.release(0)
if !waitIdle(o, "web") {
t.Fatal("会话没有回到空闲")
}
}
// ---------------------------------------------------------------------------
// ④ interrupt{turnId} 只停目标路
// ---------------------------------------------------------------------------
// TestInterruptTurnStopsOnlyTargetLeg 钉住④的编排器侧:`InterruptTurn` 只取消
// 目标那一路 —— 另一路照常跑完并落库,被取消的那一路不补说明(现状语义),
// session_interrupted 如实报 cancelledTurns=1 / dropped=0,且在另一路仍在飞时
// **不报空闲**。
func TestInterruptTurnStopsOnlyTargetLeg(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
if !first.Immediate || first.TurnID == "" {
t.Fatalf("第一路 = %+v, want Immediate 且带 turnId", first)
}
f.run.waitStarted(1)
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Parallel {
t.Fatalf("第二路 = %+v, want Parallel", second)
}
f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
if !f.o.InterruptTurn("web", second.TurnID) {
t.Fatalf("InterruptTurn(%q) = false, want true(该路确实在途)", second.TurnID)
}
waitActiveTurns(t, f.o, "web", 1)
intr := f.sink.ofType(EventSessionInterrupt)
if len(intr) != 1 {
t.Fatalf("session_interrupted 帧数 = %d, want 1", len(intr))
}
if got := stateInt(intr[0], "cancelledTurns"); got != 1 {
t.Fatalf("cancelledTurns = %d, want 1(只停了一路)", got)
}
if got := stateInt(intr[0], "dropped"); got != 0 {
t.Fatalf("dropped = %d, want 0(目标化打断不碰待处理队列)", got)
}
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("另一路仍在飞 ⇒ 不该报空闲,得到 %v", got)
}
// 另一路(第一路)照常跑完:它的助手回复必须落库,而被取消的第二路不补说明。
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("另一路收尾后会话没有回到空闲")
}
if got := assistantContents(f.history.records()); !slices.Equal(got, []string{storedParagraph("reply-1")}) {
t.Fatalf("助手回复 = %v, want [<p>reply-1</p>](另一路继续作答,被取消路不补说明)", got)
}
}