Files
Cyrene_v2/internal/runtime/loop/resume_abandon_test.go
T
AskaEth 88d7dd0026
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop): 所有非崩溃退出一律废弃可恢复快照(主人 2026-10-04 裁决「修」)
- 原状:只有「被取消」的回合把 checkpoint 落定为不可恢复;**模型/工具报错、预算耗尽**等未落定退出
  同样留下可恢复快照 ⇒ 下一条消息被旧窗口整段顶掉(用户看到自己刚发的消息像丢了)
- 修法:收尾 defer 改为「**回合未落定就废弃快照**」(不再只看 ctx 是否取消),新增 StopReasonAborted;
  checkpoint.resumableStopReason 收紧为「**只有 `tool_calls` 可恢复**」(空 stop reason 不再算);
  终态快照改用 context.WithoutCancel 写(修「取消擦肩最终答案」时终态写失败、中途快照留任最新)
- 语义边界(写进用例注释):**只有崩溃**(进程猝死、函数不返回)才允许恢复;控制流一旦回到调用方
  就不得留可恢复态。原 resume_test 的用例改名为**崩溃恢复**用例并保留断言(含「新消息不被顶掉」的反面断言在新用例里)
- 复核:build/vet 绿 + loop 包测试与 `-race` 全绿;新增 6 条(模型报错/工具失败+模型报错/预算耗尽/
  空 stop reason/取消擦肩最终答案)+ 端到端 TestInterruptThenChatKeepsLatestContextEndToEnd 绿;
  **4 条变异反证**(含把废弃快照写回 tool_calls → 精确复现真实劫持现场)均已还原
- 附:修 session_turn_fence.go 里引用旧函数名的陈旧注释
2026-10-04 14:17:58 +08:00

237 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()))
}