package loop import ( "context" "encoding/json" "errors" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist" ) // 本文件钉住主人 2026-10-04 的裁决:**只有崩溃才允许恢复**。任何已落定的 // 未完成退出——模型报错、工具报错、预算耗尽、安全网终止、护栏拦截—— // 都必须让该会话的可恢复快照失效,使下一条消息自己组最新上下文,而不是被 // 放弃回合的旧窗口顶掉(裁决 13.4.5:上下文一致性 / 完整性 / 最新性; // 否则用户会看到自己刚发的消息像丢了)。取消的同一断言见 // run_cancel_test.go,崩溃仍可恢复见 resume_test.go。 // lastRecorded 返回适配器已记录请求里的最后一条。 func lastRecorded(t *testing.T, reqs []model.Request) model.Request { t.Helper() if len(reqs) == 0 { t.Fatalf("适配器没有收到任何模型请求") } return reqs[len(reqs)-1] } // requireNewMessageContext 断言被放弃回合之后的下一条消息自己组了最新上下文: // 请求里有「B」,且不再带旧窗口的任何痕迹(工具名 echo 只出现在旧窗口的 // assistant 工具调用轮与工具结果消息里)。 func requireNewMessageContext(t *testing.T, req model.Request) { t.Helper() if !requestHasText(req, "B") { t.Fatalf("未落定退出之后的请求不含新消息 B:%v(上下文不最新/不完整)", requestTexts(req)) } for _, m := range req.Messages { if strings.Contains(messageText(m), "echo") { t.Fatalf("未落定退出之后的请求仍带着旧窗口(工具调用/工具结果):%v", requestTexts(req)) } } } // requireLatestStopReason 断言该会话最新的快照以 want 落定,且它不可恢复。 // 它走 LoadLatest——与 loadCheckpoint 恢复时同一道门——因此断言的是 // 「下一条消息不会被旧窗口顶掉」这个真实判定,而不是某个脆弱的字段。 func requireLatestStopReason(t *testing.T, ctx context.Context, cp *persist.Store, sessionID, want string) { t.Helper() latest, err := cp.LoadLatest(ctx, sessionID) if err != nil { t.Fatalf("LoadLatest(%s): %v", sessionID, err) } var st checkpointState if err := json.Unmarshal(latest.State, &st); err != nil { t.Fatalf("unmarshal checkpoint %s: %v", latest.ID, err) } if st.StopReason != want { t.Fatalf("最新快照 %s 的 stop_reason = %q, want %q", latest.ID, st.StopReason, want) } if resumableStopReason(st.StopReason) { t.Fatalf("最新快照 %s(stop_reason=%q)仍可恢复:下一条消息会被旧窗口顶掉", latest.ID, st.StopReason) } } // TestRun_ModelErrorDoesNotHijackNextTurn:回合 A 的步 1 请求工具(落下中途 // tool_calls 快照),步 2 的模型调用报错——这是**已落定的未完成退出**,不是 // 崩溃(崩溃时收尾 defer 根本不执行)。因此 A 留下的快照必须被落定为 aborted, // 同一会话的下一条消息 B 以 B 自己的最新上下文开始。 func TestRun_ModelErrorDoesNotHijackNextTurn(t *testing.T) { t.Parallel() ctx := context.Background() call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, {err: errors.New("model unavailable")}, {resp: model.Response{Content: "final"}}, }} td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"}) cp := newTestCheckpointStore(t) l := New(Options{Agent: newTestAgent(t, ad, td), Checkpoint: cp, MaxRounds: 8}) const sess = "sess-model-error" if _, err := l.Run(ctx, sess, []model.Message{model.TextMessage(model.RoleUser, "A")}); err == nil { t.Fatalf("回合 A 应在步 2 模型报错时返回错误") } requireLatestStopReason(t, ctx, cp, sess, StopReasonAborted) 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" || res.Steps != 1 { t.Fatalf("回合 B = %q / steps=%d, want final / 1(aborted 快照不可恢复,B 必须全新开始)", res.Text, res.Steps) } requireNewMessageContext(t, lastRecorded(t, ad.reqs)) } // TestRun_ToolFailureThenModelErrorDoesNotHijackNextTurn:工具执行失败会把 // 结构化错误回灌给模型(循环并不因此退出),随后模型调用报错才让回合未落定 // 退出。此时步 1 的中途快照同样必须被废弃,而不是等在那里劫持下一条消息。 func TestRun_ToolFailureThenModelErrorDoesNotHijackNextTurn(t *testing.T) { t.Parallel() ctx := context.Background() call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, {err: errors.New("model unavailable")}, {resp: model.Response{Content: "final"}}, }} td := dispatcherWith(t, &mockTool{name: "echo", err: errors.New("tool exploded")}) cp := newTestCheckpointStore(t) l := New(Options{Agent: newTestAgent(t, ad, td), Checkpoint: cp, MaxRounds: 8}) const sess = "sess-tool-error" if _, err := l.Run(ctx, sess, []model.Message{model.TextMessage(model.RoleUser, "A")}); err == nil { t.Fatalf("回合 A 应在模型报错时返回错误") } requireLatestStopReason(t, ctx, cp, sess, StopReasonAborted) 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" || res.Steps != 1 { t.Fatalf("回合 B = %q / steps=%d, want final / 1(工具失败+报错的快照不可恢复)", res.Text, res.Steps) } requireNewMessageContext(t, lastRecorded(t, ad.reqs)) } // TestRun_MaxRoundsDoesNotHijackNextTurn:预算耗尽是一条**已落定**的退出 // (settleAndReturn 在同一个步上覆写为终态快照 max_rounds),它同样不得让 // 下一条消息被旧窗口顶掉。它也顺带钉住「已落定 → 收尾 defer 不改写」: // 最新快照仍是该方法自己写的 max_rounds,而不是 canceled / aborted。 func TestRun_MaxRoundsDoesNotHijackNextTurn(t *testing.T) { t.Parallel() ctx := context.Background() call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, {resp: model.Response{Content: "final"}}, }} td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"}) cp := newTestCheckpointStore(t) l := New(Options{Agent: newTestAgent(t, ad, td), Checkpoint: cp, MaxRounds: 1}) const sess = "sess-max-rounds" resA, err := l.Run(ctx, sess, []model.Message{model.TextMessage(model.RoleUser, "A")}) if err != nil { t.Fatalf("Run(A): %v", err) } if resA.StopReason != StopReasonMaxRounds { t.Fatalf("回合 A 的 stop reason = %q, want %q", resA.StopReason, StopReasonMaxRounds) } requireLatestStopReason(t, ctx, cp, sess, StopReasonMaxRounds) 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" || res.Steps != 1 { t.Fatalf("回合 B = %q / steps=%d, want final / 1(预算耗尽的快照不可恢复)", res.Text, res.Steps) } requireNewMessageContext(t, lastRecorded(t, ad.reqs)) } // cancelingFinalAdapter 在交出最终答案的同一刻取消该回合的 ctx,复现「收尾 // 落定与用户中断擦肩而过」的竞态:回合本身已经得出终态,因此它的终态快照 // 必须真正写下去(见 Loop.settleAndReturn)——否则最新快照会停在中途的 // tool_calls 上,下一条消息又被旧窗口顶掉。 type cancelingFinalAdapter struct { cancel func() mu sync.Mutex n int reqs []model.Request } func (a *cancelingFinalAdapter) Name() string { return "canceling-final" } func (a *cancelingFinalAdapter) Chat(_ 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: `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`}, nil case 2: a.cancel() return model.Response{Content: "final"}, nil default: return model.Response{Content: "final"}, nil } } func (a *cancelingFinalAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) { return a.Chat(ctx, req, model.Options{}) } func (a *cancelingFinalAdapter) recorded() []model.Request { a.mu.Lock() defer a.mu.Unlock() return append([]model.Request(nil), a.reqs...) } // TestRun_CancelRacingFinalAnswerStillSettlesCheckpoint:回合在交出最终答案的 // 瞬间被取消——它**已经落定**(有终态可交付),因此不得被当成中断丢弃: // 终态快照必须落盘,下一条消息读到的是它而不是中途的 tool_calls。 func TestRun_CancelRacingFinalAnswerStillSettlesCheckpoint(t *testing.T) { t.Parallel() ctx := context.Background() runCtx, cancel := context.WithCancel(ctx) ad := &cancelingFinalAdapter{cancel: cancel} td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"}) cp := newTestCheckpointStore(t) l := New(Options{Agent: newTestAgent(t, ad, td), Checkpoint: cp, MaxRounds: 8}) const sess = "sess-cancel-race" res, err := l.Run(runCtx, sess, []model.Message{model.TextMessage(model.RoleUser, "A")}) if err != nil { t.Fatalf("Run(A): %v(终态已得出,取消不得把这一轮变成错误)", err) } if res.Text != "final" || res.StopReason != agent.StopReasonStop { t.Fatalf("回合 A = %q / %q, want final / %q", res.Text, res.StopReason, agent.StopReasonStop) } requireLatestStopReason(t, ctx, cp, sess, agent.StopReasonStop) if _, err := l.Run(ctx, sess, []model.Message{model.TextMessage(model.RoleUser, "B")}); err != nil { t.Fatalf("Run(B): %v", err) } requireNewMessageContext(t, lastRecorded(t, ad.recorded())) }