Files
Cyrene_v2/internal/services/gateway/interrupt_scope_unit_test.go
T
AskaEth 5494c31474
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): interrupt 支持 scope(当前/全部),默认维持现状(协同 A2)
- 原状:interrupt{sessionId} 取消在飞回合**并清空 pending** ⇒ 客户端「打断模式」连发会被静默丢弃
- 修法:新增 scope 三态(与项目既有「省略=保留现状」口径一致)
  · 省略 或 "all" = 现状语义(取消当前 + 清 pending)
  · "current" = **只取消当前回合、保留 pending**(pending 继续按编排器规则续跑)
  · 非法值 → **定向回错误**(只回发送者,不广播),显式报错而非静默当默认
- 下行 session_interrupted 的 dropped 如实反映**真的被丢弃的条数**(scope=current 时通常为 0)
- 复核:gateway 包 build/vet/测试绿(新增 interrupt_scope_* 两组用例);后端 :10942 全程未停
2026-10-04 14:21:52 +08:00

189 lines
6.4 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"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是协同项 A2 的单元级断言(编排器 + scope 取值域),与
// interrupt_scope_test.go 的 WS 端到端用例配套。两处合起来钉住:
// `interrupt {sessionId, scope}` 的省略 / "all" / "current" / 非法值四种口径。
// 拆成两个文件是为了守住「单文件 ≤500 行」的硬约束。
// a2HoldRunner 与 a2Runner 同形,但 ctx 被取消后**仍不返回**(真实 loop 会
// 立刻返回,这里刻意留在 Run 里),使「pending 在打断那一刻真的还在」有一个
// 不被续跑打断的确定性窗口。release 关闭后它才按其 ctx 状态返回。
type a2HoldRunner struct {
mu sync.Mutex
calls []string
release chan struct{}
}
func newA2HoldRunner() *a2HoldRunner { return &a2HoldRunner{release: make(chan struct{})} }
func (r *a2HoldRunner) Run(ctx context.Context, _ string, msgs []model.Message, _ loop.SessionEnv) (*loop.Result, error) {
text := ""
if n := len(msgs); n > 0 {
text = messageText(msgs[n-1])
}
r.mu.Lock()
r.calls = append(r.calls, text)
r.mu.Unlock()
<-r.release
if err := ctx.Err(); err != nil {
return nil, err
}
return &loop.Result{Text: "reply-" + text, Steps: 1, StopReason: "stop"}, nil
}
func (r *a2HoldRunner) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.calls)
}
func (r *a2HoldRunner) texts() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.calls...)
}
// a2Event 是一次被捕获的状态转移。
type a2Event struct {
typ string
data map[string]any
}
// a2Sink 记录编排器发出的每一次状态转移(含 data),使「帧里报的 dropped /
// mode」能在单元级被断言,而不必先架一条 WS。它与 recordingSink 分开是为了
// 让本组用例不依赖别的测试文件的数据结构。
type a2Sink struct {
mu sync.Mutex
events []a2Event
}
func (s *a2Sink) Emit(_, typ string, data map[string]any) {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, a2Event{typ: typ, data: data})
}
// lastInterrupt 返回最后一次 session_interrupted 的 mode 与 dropped。mode 在
// 编排器里是 Mode 类型(不是 string),因此两种形状都接受。
func (s *a2Sink) lastInterrupt() (string, int, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for i := len(s.events) - 1; i >= 0; i-- {
if s.events[i].typ != EventSessionInterrupt {
continue
}
var mode string
switch v := s.events[i].data["mode"].(type) {
case Mode:
mode = string(v)
case string:
mode = v
}
dropped, _ := s.events[i].data["dropped"].(int)
return mode, dropped, true
}
return "", 0, false
}
// TestInterruptScopeCurrentKeepsPending 是 A2 的单元级核心断言:scope=current
// 只取消当前回合,待处理队列一条不丢(确定性地断言队列仍在,因为 a2HoldRunner
// 被取消后仍留在 Run 里),状态帧如实报告 dropped=0 且 mode 不是 idle;随后被
// 保留的消息真的按续跑规则执行。
func TestInterruptScopeCurrentKeepsPending(t *testing.T) {
t.Parallel()
run := newA2HoldRunner()
sink := &a2Sink{}
o := NewOrchestrator(OrchestratorOptions{Run: run.Run, Emit: sink.Emit, MaxPending: 5, MaxTail: 20})
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
waitUntil(t, "回合 A 在飞", func() bool { return run.count() == 1 })
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("B = %+v, want Pending", sr)
}
if o.QueueDepth("s1") != 1 {
t.Fatalf("queue depth = %d, want 1(B 必须已经入队)", o.QueueDepth("s1"))
}
if dropped := o.InterruptScope("s1", InterruptScopeCurrent); dropped != 0 {
t.Fatalf("dropped = %d, want 0(current 口径不丢弃任何待处理消息)", dropped)
}
// 确定性窗口:A 尚未结算,因此队列与忙态就是打断那一刻的真实状态。
if got := o.QueueDepth("s1"); got != 1 {
t.Fatalf("queue depth = %d, want 1(scope=current 必须保留待处理队列)", got)
}
if !o.Busy("s1") {
t.Fatal("scope=current 只取消当前回合,会话不该被报成空闲(队列还在续跑)")
}
if got := run.texts(); len(got) != 1 || got[0] != "A" {
t.Fatalf("driven = %v, want [A](此时不得有任何续跑回合)", got)
}
mode, dropped, ok := sink.lastInterrupt()
if !ok {
t.Fatal("没有发出 session_interrupted(打断必须如实上报发生了什么)")
}
if dropped != 0 {
t.Fatalf("session_interrupted.dropped = %d, want 0(真的没有消息被丢弃)", dropped)
}
if mode != string(ModeThinking) {
t.Fatalf("session_interrupted.mode = %q, want %q(会话仍在收尾并续跑队列)", mode, ModeThinking)
}
// 放行 A 的收尾:被保留的 B 必须带着会话尾部上下文真的续跑。
close(run.release)
waitUntil(t, "被保留的 B 续跑", func() bool { return run.count() == 2 })
if got := run.texts(); len(got) != 2 || got[0] != "A" || got[1] != "B" {
t.Fatalf("driven = %v, want [A B](pending 必须续跑,绝不能静默丢弃)", got)
}
if !waitIdle(o, "s1") {
t.Fatalf("会话没有安定:busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
}
// TestParseInterruptScopeValueDomain 钉住 scope 的取值域:省略 / 空串 / "all"
// 落到现状语义,"current" 落到保留队列口径,其余一律报错 —— 未知取值被悄悄当成
// 默认执行,正是 A2 要修掉的那类静默丢消息。
func TestParseInterruptScopeValueDomain(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
want InterruptScope
wantErr bool
}{
{"", InterruptScopeAll, false},
{"all", InterruptScopeAll, false},
{"current", InterruptScopeCurrent, false},
{" current ", InterruptScopeCurrent, false},
{"Current", "", true},
{"ALL", "", true},
{"pending", "", true},
{"both", "", true},
{"*", "", true},
}
for _, tc := range cases {
got, err := parseInterruptScope(tc.raw)
if tc.wantErr {
if err == nil {
t.Fatalf("scope=%q 被接受为 %q:未知取值必须报错,不得静默按默认执行", tc.raw, got)
}
continue
}
if err != nil {
t.Fatalf("scope=%q: %v", tc.raw, err)
}
if got != tc.want {
t.Fatalf("scope=%q = %q, want %q", tc.raw, got, tc.want)
}
}
}