- 原状:interrupt{sessionId} 取消在飞回合**并清空 pending** ⇒ 客户端「打断模式」连发会被静默丢弃
- 修法:新增 scope 三态(与项目既有「省略=保留现状」口径一致)
· 省略 或 "all" = 现状语义(取消当前 + 清 pending)
· "current" = **只取消当前回合、保留 pending**(pending 继续按编排器规则续跑)
· 非法值 → **定向回错误**(只回发送者,不广播),显式报错而非静默当默认
- 下行 session_interrupted 的 dropped 如实反映**真的被丢弃的条数**(scope=current 时通常为 0)
- 复核:gateway 包 build/vet/测试绿(新增 interrupt_scope_* 两组用例);后端 :10942 全程未停
189 lines
6.4 KiB
Go
189 lines
6.4 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|