package loop import ( "context" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件锁定裁决 13.4.5 在「回合被用户中断之后」的上下文口径(design/15 §3): // 中断后**立即处理下一条消息**,且它必须带着**最新**上下文——被放弃的回合 // 绝不能把它的可恢复 checkpoint 留给下一条消息续接(否则新消息会被旧窗口顶掉, // 上下文既不最新也不完整)。 // cancelingAdapter 复现「用户中断正在跑的回合」:第一次 Chat 交回一个工具调用, // 第二次 Chat 阻塞到 ctx 被取消并把 ctx 的错误交回,之后每次都直接收尾。 // 它使一次回合能在**已写入可恢复 checkpoint**(工具步之后)之后才被中断。 type cancelingAdapter struct { toolCall string mu sync.Mutex n int reqs []model.Request } func (a *cancelingAdapter) Name() string { return "canceling" } func (a *cancelingAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) { a.mu.Lock() a.n++ n := a.n a.reqs = append(a.reqs, req) a.mu.Unlock() switch n { case 1: return model.Response{Content: a.toolCall}, nil case 2: <-ctx.Done() return model.Response{}, ctx.Err() default: return model.Response{Content: "final"}, nil } } func (a *cancelingAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { return a.Chat(ctx, req, model.Options{}) } // requests 返回被记录模型请求的快照。 func (a *cancelingAdapter) requests() []model.Request { a.mu.Lock() defer a.mu.Unlock() return append([]model.Request(nil), a.reqs...) } // TestRun_CanceledTurnDoesNotHijackNextTurn 是 13.4.5 的中断续接用例: // 回合 A 在工具循环中途被中断(design/15 §3),同一会话的下一条消息 B 必须 // **以 B 自己的最新上下文**开始,不得继承 A 的旧窗口。 // // A 的窗口里带着工具结果、且**不含 B**,因此「续接 A」就等于把 B 丢掉 —— // 这正是本用例要挡住的上下文不完整/不最新。用例跑在真实 persist.Store 上, // 因此 upsert 与 LoadLatest 的取新语义都是真的。 func TestRun_CanceledTurnDoesNotHijackNextTurn(t *testing.T) { t.Parallel() ctx := context.Background() ad := &cancelingAdapter{toolCall: `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`} td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"}) a := newTestAgent(t, ad, td) cp := newTestCheckpointStore(t) l := New(Options{Agent: a, Checkpoint: cp, MaxRounds: 8}) const sess = "sess-cancel" // 回合 A:步 1 请求工具(落下一个 resumable 的 tool_calls checkpoint), // 步 2 的模型调用期间被中断。 runCtx, cancel := context.WithCancel(ctx) done := make(chan struct{}) go func() { defer close(done) if _, err := l.Run(runCtx, sess, []model.Message{model.TextMessage(model.RoleUser, "A")}); err == nil { t.Errorf("canceled turn returned no error, want the cancellation error") } }() waitAdapterCall(t, ad, 2) cancel() <-done // 下一条消息 B:必须看到 B 自己,而不是 A 的旧窗口。 res, err := l.Run(ctx, sess, []model.Message{model.TextMessage(model.RoleUser, "B")}) if err != nil { t.Fatalf("Run(B): %v", err) } if res.Text != "final" { t.Fatalf("Text = %q, want final", res.Text) } reqs := ad.requests() last := reqs[len(reqs)-1] if !requestHasText(last, "B") { t.Fatalf("被中断回合之后的请求不含新消息 B:%v(上下文不最新/不完整)", requestTexts(last)) } for _, m := range last.Messages { if strings.Contains(messageText(m), "tool echo result") { t.Fatalf("被中断回合之后的请求仍带着旧窗口的工具结果:%v", requestTexts(last)) } } } // requestHasText 报告请求中是否有一条消息携带该精确文本。 func requestHasText(req model.Request, want string) bool { for _, m := range req.Messages { if messageText(m) == want { return true } } return false } // requestTexts 汇总请求里每条消息的文本,供失败信息可读。 func requestTexts(req model.Request) []string { out := make([]string, 0, len(req.Messages)) for _, m := range req.Messages { out = append(out, string(m.Role)+":"+messageText(m)) } return out } // waitAdapterCall 阻塞到适配器已被调用 n 次(使中断精确发生在第二次模型调用 // 的阻塞期内),超时即失败而不是静默继续。 func waitAdapterCall(t *testing.T, ad *cancelingAdapter, n int) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { ad.mu.Lock() got := ad.n ad.mu.Unlock() if got >= n { return } time.Sleep(time.Millisecond) } t.Fatalf("adapter calls = %d, want >= %d", n, n) }