- 原状:只有「被取消」的回合把 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 里引用旧函数名的陈旧注释
237 lines
9.8 KiB
Go
237 lines
9.8 KiB
Go
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()))
|
||
}
|