Files
Cyrene_v2/internal/services/gateway/session_orchestrator_test.go
T
AskaEth f43751fd07
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(history): 消息正向增量拉取(A5) — GET /sessions/{id}/messages?after=
- history/list_after.go: ListAfter(升序, created_at>after, 走覆盖索引, rowid兜底同毫秒)
- HistoryStore 新增 ListAfter(store+fakes同步)
- gateway history.go: handleListMessages解析after分流; after+before互斥400; 负数/非数字400
- 新增测试(list_after/history_after), build/vet/test绿+交叉编译
- Docs/api-client v0.26 + api-reference §3.4 同步
2026-09-08 21:49:12 +08:00

705 lines
21 KiB
Go

package gateway
import (
"context"
"fmt"
"log/slog"
"sync"
"testing"
"time"
"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"
)
// callRecord is one invocation captured by the fake runner.
type callRecord struct {
sessionID string
messages []model.Message
env loop.SessionEnv
ctx context.Context
}
// gatedRunner is a controllable TurnRunner. Each Run blocks until it consumes one
// ticket from tickets, or until ctx is canceled (the interrupt path). When it
// proceeds it records the call and returns a deterministic reply. A nil err
// produces a reply holding the *incoming* messages as text, so a test can assert
// exactly what context the resumed turn carried.
type gatedRunner struct {
mu sync.Mutex
calls []callRecord
tickets chan struct{}
err error
}
func newGatedRunner(cap int) *gatedRunner {
return &gatedRunner{tickets: make(chan struct{}, cap)}
}
func (g *gatedRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
select {
case <-g.tickets:
case <-ctx.Done():
return nil, ctx.Err()
}
g.mu.Lock()
n := len(g.calls) + 1
g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx})
g.mu.Unlock()
if g.err != nil {
return nil, g.err
}
reply := n + len(messages)
return &loop.Result{Text: fmt.Sprintf("reply-%d", reply), Steps: 1, StopReason: "stop"}, nil
}
// waitCalls blocks until at least n calls are recorded and returns a copy of the
// first n, so a test can advance the gated chain deterministically.
func (g *gatedRunner) waitCalls(n int) []callRecord {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
g.mu.Lock()
if len(g.calls) >= n {
out := append([]callRecord(nil), g.calls[:n]...)
g.mu.Unlock()
return out
}
g.mu.Unlock()
time.Sleep(time.Millisecond)
}
panic("timed out waiting for runner calls")
}
// count returns the total recorded calls.
func (g *gatedRunner) count() int {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.calls)
}
// eventRec is one state transition captured by the recording sink.
type eventRec struct {
sessionID, typ string
data map[string]any
}
// recordingSink captures every emitted state transition.
type recordingSink struct {
mu sync.Mutex
events []eventRec
}
func (s *recordingSink) Emit(sessionID, typ string, data map[string]any) {
s.mu.Lock()
s.events = append(s.events, eventRec{sessionID: sessionID, typ: typ, data: data})
s.mu.Unlock()
}
func (s *recordingSink) types() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.events))
for _, e := range s.events {
out = append(out, e.typ)
}
return out
}
func (s *recordingSink) last() (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.events) == 0 {
return "", false
}
return s.events[len(s.events)-1].typ, true
}
// userMsg builds a text-only user message.
func userMsg(text string) model.Message {
return model.TextMessage(model.RoleUser, text)
}
// messageText returns the concatenated text of a message's text parts.
func messageText(m model.Message) string {
var out string
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out += c.Text
}
}
return out
}
// seedOrchestrator builds an orchestrator with a gated runner and a recording
// sink, both returned so the test can drive the chain and assert state events.
func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordingSink) {
sink := &recordingSink{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
Log: slog.Default(),
MaxPending: maxPending,
MaxTail: 20,
})
return o, sink
}
// readOnlyEnv returns the group-chat / stranger read-only memory lane.
func readOnlyEnv() loop.SessionEnv {
return loop.SessionEnv{MemWritable: false}
}
// fakeHistory records every Append so tests assert the orchestrator persists the
// user stream and the assistant reply into the chat-history store.
type fakeHistory struct {
mu sync.Mutex
appends []history.Message
}
func (f *fakeHistory) Append(ctx context.Context, msg history.Message) error {
_, err := f.AppendFull(ctx, msg)
return err
}
func (f *fakeHistory) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
f.mu.Lock()
defer f.mu.Unlock()
if msg.ID == "" {
msg.ID = fmt.Sprintf("id-%d", len(f.appends)+1)
}
if msg.CreatedAt == 0 {
msg.CreatedAt = int64(len(f.appends) + 1)
}
f.appends = append(f.appends, msg)
return msg, nil
}
func (f *fakeHistory) List(context.Context, string, int64, int, string) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) ListAfter(context.Context, string, int64, int, string) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) Recent(context.Context, string, int) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) ListPlatformLog(context.Context, string, int64, int) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) ListAdapterLog(context.Context, string, int64, int) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) ListAllLog(context.Context, int64, int) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) DeleteSession(context.Context, string) error {
return nil
}
func (f *fakeHistory) RenameSession(context.Context, string, string) error {
return nil
}
func (f *fakeHistory) NewSubSession(context.Context, string) (string, error) {
return "", nil
}
func (f *fakeHistory) ListSessions(context.Context) ([]history.SessionMeta, error) {
return nil, nil
}
func (f *fakeHistory) records() []history.Message {
f.mu.Lock()
defer f.mu.Unlock()
return append([]history.Message(nil), f.appends...)
}
func TestSubmitPersistsUserAndAssistant(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Log: slog.Default(),
History: hist,
})
run.tickets <- struct{}{}
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
if res == nil {
t.Fatal("sync result is nil")
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
if recs[0].SessionID != "web" || recs[0].Role != history.RoleUser || recs[0].Content != "你好" {
t.Fatalf("user record = %+v", recs[0])
}
if recs[1].SessionID != "web" || recs[1].Role != history.RoleAssistant || recs[1].Content == "" {
t.Fatalf("assistant record = %+v", recs[1])
}
// No mode on the ctx → both fall back to "chat" (design/16 §5.1).
for i, rec := range recs {
if rec.Mode != "chat" {
t.Fatalf("record[%d] Mode = %q, want chat fallback", i, rec.Mode)
}
}
}
func TestSubmitPersistsMessageMode(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Log: slog.Default(),
History: hist,
})
run.tickets <- struct{}{}
// The HTTP/WS boundary threads the client's behavior mode through ctx
// (design/01 §3, design/16 §5.1); both the user turn and the assistant reply
// must be persisted under that mode.
ctx := withMode(context.Background(), "code")
sr, res := o.SubmitSync(ctx, "web", []model.Message{userMsg("改代码")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
if res == nil {
t.Fatal("sync result is nil")
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
if recs[0].Mode != "code" {
t.Fatalf("user Mode = %q, want code", recs[0].Mode)
}
if recs[1].Mode != "code" {
t.Fatalf("assistant Mode = %q, want code", recs[1].Mode)
}
}
func TestSubmitPersistsDeviceID(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Log: slog.Default(),
History: hist,
})
run.tickets <- struct{}{}
// The HTTP/WS boundary threads the browser deviceID through ctx (design/16 §4.5);
// both the user turn and the assistant reply must be tagged with it.
ctx := withDeviceID(context.Background(), "web:chrome:a1b2c3")
sr, res := o.SubmitSync(ctx, "web", []model.Message{userMsg("你好")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
if res == nil {
t.Fatal("sync result is nil")
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
if recs[0].Role != history.RoleUser || recs[0].Content != "你好" || recs[0].DeviceID != "web:chrome:a1b2c3" {
t.Fatalf("user record = %+v, want DeviceID web:chrome:a1b2c3", recs[0])
}
if recs[1].Role != history.RoleAssistant || recs[1].Content == "" || recs[1].DeviceID != "web:chrome:a1b2c3" {
t.Fatalf("assistant record = %+v, want DeviceID web:chrome:a1b2c3", recs[1])
}
}
func TestSubmitPersistedBroadcast(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
hist := &fakeHistory{}
var mu sync.Mutex
var persisted []history.Message
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Log: slog.Default(),
History: hist,
OnPersisted: func(m history.Message) {
mu.Lock()
persisted = append(persisted, m)
mu.Unlock()
},
})
run.tickets <- struct{}{}
sr, _ := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
mu.Lock()
got := append([]history.Message(nil), persisted...)
mu.Unlock()
if len(got) != 2 {
t.Fatalf("persisted = %d, want 2 (user + assistant)", len(got))
}
if got[0].Role != history.RoleUser || got[0].ID == "" || got[0].SessionID != "web" || got[0].Content != "你好" {
t.Fatalf("user persisted = %+v", got[0])
}
if got[1].Role != history.RoleAssistant || got[1].ID == "" || got[1].SessionID != "web" || got[1].Content == "" {
t.Fatalf("assistant persisted = %+v", got[1])
}
if got[0].ID == got[1].ID {
t.Fatalf("user and assistant share id %q", got[0].ID)
}
}
func TestSubmitDoesNotPersistRejectedTurn(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{Run: newGatedRunner(0).Run, Log: slog.Default(), History: hist, MaxPending: 1})
// Empty turn is rejected before any persistence.
sr := o.Submit(context.Background(), "web", nil, loop.OwnerEnv())
if sr.Accepted || !sr.Empty {
t.Fatalf("empty submit = %+v, want Empty reject", sr)
}
if n := len(hist.records()); n != 0 {
t.Fatalf("appends = %d, want 0 for a rejected turn", n)
}
}
func TestSubmitIdleStartsImmediate(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, _ := seedOrchestrator(run, 5)
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
run.tickets <- struct{}{}
got := run.waitCalls(1)
if len(got) != 1 {
t.Fatalf("calls = %d, want 1", len(got))
}
if o.Busy("s1") {
t.Fatal("session should be idle after a completed turn")
}
}
func TestSubmitBusyPendingNotDropped(t *testing.T) {
t.Parallel()
run := newGatedRunner(4)
o, sink := seedOrchestrator(run, 5)
// Turn A starts and blocks (no ticket yet), so the session is busy.
a := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
if !a.Immediate {
t.Fatalf("A = %+v, want Immediate", a)
}
// B and C arrive while A is in flight: they must be queued, not dropped.
b := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv())
c := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv())
if !b.Accepted || !b.Pending || b.QueueDepth != 1 {
t.Fatalf("B = %+v, want Pending depth=1", b)
}
if !c.Accepted || !c.Pending || c.QueueDepth != 2 {
t.Fatalf("C = %+v, want Pending depth=2", c)
}
if o.QueueDepth("s1") != 2 {
t.Fatalf("queue depth = %d, want 2", o.QueueDepth("s1"))
}
// Release the chain: A, then B, then C. The emitted ack should include the
// pending depth, and the sink should have seen pending_ack events.
types := sink.types()
foundAck := false
for _, ty := range types {
if ty == EventPendingAck {
foundAck = true
}
}
if !foundAck {
t.Fatalf("no pending_ack in events: %v", types)
}
run.tickets <- struct{}{}
run.tickets <- struct{}{}
run.tickets <- struct{}{}
if n := len(run.waitCalls(3)); n != 3 {
t.Fatalf("total calls = %d, want 3 (A+B+C)", n)
}
if o.QueueDepth("s1") != 0 || o.Busy("s1") {
t.Fatalf("session should be idle and empty after drain: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
}
func TestSubmitBusyResumeCarriesPreviousContext(t *testing.T) {
t.Parallel()
run := newGatedRunner(3)
o, _ := seedOrchestrator(run, 5)
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv()); !sr.Pending {
t.Fatalf("B = %+v, want Pending", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("C = %+v, want Pending", sr)
}
run.tickets <- struct{}{} // A
run.tickets <- struct{}{} // B
run.tickets <- struct{}{} // C
got := run.waitCalls(3)
// A starts from a blank turn.
if len(got[0].messages) != 1 || messageText(got[0].messages[0]) != "A" {
t.Fatalf("A messages = %v, want [A]", messageTextList(got[0].messages))
}
// B must resume carrying A's assistant reply folded into the tail.
if !matchesText(got[1].messages, "A", "B") {
t.Fatalf("B messages = %v, want B to resume carrying the prior tail", messageTextList(got[1].messages))
}
// C must resume carrying A's and B's assistant replies.
if !matchesText(got[2].messages, "A", "C") {
t.Fatalf("C messages = %v, want C to resume carrying the prior tail", messageTextList(got[2].messages))
}
// B's env must be the one the message arrived with (read-only lane).
if got[1].env.MemWritable {
t.Fatal("B resumed with MemWritable=true, want the caller's read-only env")
}
}
// matchesText reports whether a resumed turn begins with the captured first user
// turn (so the prior context was carried over), ends with the expected new user
// turn, and contains at least one assistant reply in between.
func matchesText(msgs []model.Message, first, last string) bool {
if len(msgs) < 3 {
return false
}
if messageText(msgs[0]) != first {
return false
}
if messageText(msgs[len(msgs)-1]) != last {
return false
}
for i := 1; i < len(msgs)-1; i++ {
if msgs[i].Role == model.RoleAssistant {
return true
}
}
return false
}
func messageTextList(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, messageText(m))
}
return out
}
func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, _ := seedOrchestrator(run, 3) // tight queue
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
for i := 0; i < 3; i++ {
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("p%d", i))}, loop.OwnerEnv())
if !sr.Pending || sr.QueueDepth != i+1 {
t.Fatalf("pending %d = %+v, want Pending depth=%d", i, sr, i+1)
}
}
// Queue is at capacity (3), so the next message is refused, not dropped.
over := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv())
if over.Accepted || !over.PendingFull {
t.Fatalf("overflow = %+v, want PendingFull reject", over)
}
if o.QueueDepth("s1") != 3 {
t.Fatalf("queue depth = %d, want 3 (unchanged)", o.QueueDepth("s1"))
}
}
func TestInterruptCancelsTurnAndClearsPending(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("A = %+v, want Immediate", sr)
}
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv()); !sr.Pending {
t.Fatalf("B = %+v, want Pending", sr)
}
// Release no ticket: A is blocked in flight. Interrupt cancels it and clears B.
dropped := o.Interrupt("s1")
if dropped != 1 {
t.Fatalf("dropped = %d, want 1", dropped)
}
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
t.Fatalf("session not idle after interrupt: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
}
// A new message after the interrupt must start fresh (Immediate), and the
// canceled chain must not have resumed B.
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("C = %+v, want Immediate after interrupt", sr)
}
run.tickets <- struct{}{}
got := run.waitCalls(1)
if len(got) != 1 {
t.Fatalf("calls = %d, want 1 (only C, not the canceled A or B)", len(got))
}
if messageText(got[0].messages[0]) != "C" {
t.Fatalf("only surviving call = %v, want the C turn", messageTextList(got[0].messages))
}
}
func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) {
t.Parallel()
run := newGatedRunner(8)
o, _ := seedOrchestrator(run, 3)
const senders = 8
var wg sync.WaitGroup
accepted := make(chan SubmitResult, senders)
for i := 0; i < senders; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
accepted <- o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("m%d", i))}, loop.OwnerEnv())
}(i)
}
wg.Wait()
close(accepted)
immediate, pending, full := 0, 0, 0
for sr := range accepted {
switch {
case sr.Immediate:
immediate++
case sr.Pending:
pending++
case sr.PendingFull:
full++
default:
t.Fatalf("unexpected submit result: %+v", sr)
}
}
// With maxPending=3: exactly 1 turn runs immediately and up to 3 queue behind
// it; the remaining senders are refused at capacity rather than dropped.
if immediate != 1 {
t.Fatalf("immediate = %d, want 1", immediate)
}
if pending != 3 {
t.Fatalf("pending = %d, want 3", pending)
}
if full != senders-4 {
t.Fatalf("pendingFull = %d, want %d", full, senders-4)
}
if o.QueueDepth("s1") != 3 {
t.Fatalf("queue depth = %d, want 3", o.QueueDepth("s1"))
}
// Drain the chain: 1 running + 3 queued = 4 turns. No panic / no lost message.
for i := 0; i < 4; i++ {
run.tickets <- struct{}{}
}
if n := len(run.waitCalls(4)); n != 4 {
t.Fatalf("total calls = %d, want 4", n)
}
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
t.Fatalf("session should be idle and empty after drain")
}
}
func TestSubmitEmptyRejected(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, _ := seedOrchestrator(run, 5)
sr := o.Submit(context.Background(), "s1", nil, loop.OwnerEnv())
if sr.Accepted || !sr.Empty {
t.Fatalf("empty submit = %+v, want Empty reject", sr)
}
if run.count() != 0 {
t.Fatalf("runner invoked for an empty turn")
}
}
func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
// Idle → synchronous run returns the reply immediately. Push a ticket so the
// gated runner's blocking select resolves during the synchronous wait.
run.tickets <- struct{}{}
sr, res := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("sync idle = %+v, want Accepted+Immediate", sr)
}
if res == nil {
t.Fatal("sync idle should return a result")
}
// Hold a busy turn in flight via an async Submit (blocked, no ticket), then a
// sync submit must return a Pending ack without blocking and without a result.
if sr2 := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr2.Immediate {
t.Fatalf("async A = %+v, want Immediate", sr2)
}
sr3, res3 := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
if !sr3.Accepted || !sr3.Pending {
t.Fatalf("sync busy = %+v, want Pending", sr3)
}
if res3 != nil {
t.Fatal("sync busy should not return a result (must not block)")
}
run.tickets <- struct{}{} // let A finish so the orchestrator is idle again
}
func TestInterruptAllClearsEverySession(t *testing.T) {
t.Parallel()
run := newGatedRunner(2)
o, _ := seedOrchestrator(run, 5)
o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
o.Submit(context.Background(), "s2", []model.Message{userMsg("X")}, loop.OwnerEnv())
if got := o.InterruptAll(); got != 1 {
t.Fatalf("InterruptAll dropped = %d, want 1 (B)", got)
}
if o.Busy("s1") || o.Busy("s2") {
t.Fatal("all sessions should be idle after InterruptAll")
}
}
func TestSetModeBusyAndIdle(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
o, sink := seedOrchestrator(run, 5)
o.SetMode("s1", ModeExecuting)
if !o.Busy("s1") || o.ModeOf("s1") != ModeExecuting {
t.Fatalf("after executing: busy=%v mode=%q", o.Busy("s1"), o.ModeOf("s1"))
}
o.SetMode("s1", ModeIdle)
if o.Busy("s1") {
t.Fatal("session must be idle after SetMode(idle)")
}
if ty, ok := sink.last(); !ok || ty != EventSessionIdle {
t.Fatalf("last event = %q, want session_idle", ty)
}
}