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) } } }