- history/message_ops.go(新): Search(会话内, q/after互斥, LIKE参数化+%/_转义+大小写不敏感, 严格限定会话doc) / UpdateMessage(返回更新后Message) / DeleteMessage / messageByID / escapeLike
- history/session_pin.go(新): SetPinned; 迁移0009 chat_sessions加pinned INTEGER DEFAULT 0; store.go SessionMeta.Pinned + createSessionsTable加列
- session_preview.go: 查询加s.pinned + ORDER BY pinned DESC,(type=main) DESC,created_at ASC
- gateway history_write.go(新,保持history.go≤500): handlePatchSession(pinned+title) / handlePatchMessage / handleDeleteMessage; server.go加PATCH/DELETE /api/v1/messages/{id} + /sessions/{id}置顶
- history.go: handleListMessages读q分流Search; 契约PATCH title/置顶
- D2会话内搜索(不做跨会话,隔离会话不外泄红线); D3不做重发端点(客户端编辑后再发); D5用pinned bool
- 测试(message_ops/history_messages/migrate_pinned), build/vet/test绿+3平台交叉编译; Docs/api-client v0.30
- 未做: 编辑/删除WS实时广播(message_edited/deleted), 下批
721 lines
22 KiB
Go
721 lines
22 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) Search(context.Context, string, string, int64, int) ([]history.Message, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeHistory) UpdateMessage(_ context.Context, id, content string) (history.Message, error) {
|
|
return history.Message{ID: id, Content: content}, nil
|
|
}
|
|
|
|
func (f *fakeHistory) DeleteMessage(context.Context, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeHistory) RenameSession(context.Context, string, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeHistory) SetPinned(context.Context, string, bool) 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)
|
|
}
|
|
}
|