feat(hitl): 增量2a —— board 可序列化(compose checkpoint 的状态载体)

compose checkpoint 把图执行态(含 GenLocalState 的 board)序列化进 store,但
Eino 序列化器只认导出字段,而 board 字段全未导出 → 直接持久化会落成空、resume
丢全部状态。Eino 对同时实现 json.Marshaler+Unmarshaler 的类型改走自定义 JSON
(internal/serialization checkMarshaler),故给 *board 实现一对 JSON 方法映射到
导出 DTO,无需把 board 字段全导出(牵连几十处调用点)。

- board_serde.go: *board 的 MarshalJSON/UnmarshalJSON ↔ boardSnapshot;丢弃
  fatalErr(transient error,中断点必为 nil);schema.RegisterName[*board] 注册
  类型名供 checkpoint 的 State(any) 还原。
- 测试: 快照往返无损 + 编译期断言实现 json.Marshaler/Unmarshaler + fatalErr
  不被持久化。

零行为变更(纯新增)。go test ./... 全绿。
下一步 2b:审批节点 compose.Interrupt + 编译挂 checkpoint store + orchestrator
识别 InterruptInfo 置 waiting 并释放 goroutine。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-29 12:38:23 +08:00
parent f3bf42c432
commit 5a1a994cc2
2 changed files with 139 additions and 0 deletions
@@ -0,0 +1,64 @@
package eino
import (
"encoding/json"
"reflect"
"testing"
"github.com/cloudwego/eino/schema"
)
// board 必须同时实现 json.Marshaler+UnmarshalerEino checkpoint 才会走自定义 JSON 路径
// 持久化未导出字段;任一缺失都会让序列化器回退到「只存导出字段」=空 board。
var (
_ json.Marshaler = (*board)(nil)
_ json.Unmarshaler = (*board)(nil)
)
// TestBoardSnapshotRoundtrip 钉死黑板快照往返:中断点的全部执行态都能无损还原(resume 不丢状态)。
func TestBoardSnapshotRoundtrip(t *testing.T) {
orig := &board{
uid: "u42",
sid: "s7",
query: "介绍杭州西湖",
profile: "用户偏好简洁",
history: []*schema.Message{schema.UserMessage("上一轮"), schema.AssistantMessage("上一轮回答", nil)},
kb: "u42/travel",
refs: []string{"西湖十景…", "苏堤春晓…"},
toolOut: []string{"[wiki] 杭州…"},
sections: []reportSection{
{Heading: "概况", Body: "西湖位于杭州。"},
},
answer: "西湖是…",
agentOut: []string{"草稿一", "草稿二"},
// fatalErr 故意置非空:快照应丢弃它(transient),不应影响往返。
fatalErr: errRejected,
}
data, err := json.Marshal(orig)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got board
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// 持久化字段逐一比对。
if got.uid != orig.uid || got.sid != orig.sid || got.query != orig.query ||
got.profile != orig.profile || got.kb != orig.kb || got.answer != orig.answer {
t.Fatalf("标量字段未还原: %+v", got)
}
if !reflect.DeepEqual(got.refs, orig.refs) || !reflect.DeepEqual(got.toolOut, orig.toolOut) ||
!reflect.DeepEqual(got.agentOut, orig.agentOut) || !reflect.DeepEqual(got.sections, orig.sections) {
t.Fatalf("切片字段未还原: %+v", got)
}
if len(got.history) != len(orig.history) || got.history[0].Content != "上一轮" {
t.Fatalf("history 未还原: %+v", got.history)
}
// fatalErr 是 transient,快照不持久化 → 还原后必为 nil。
if got.fatalErr != nil {
t.Fatalf("fatalErr 不应被持久化,got %v", got.fatalErr)
}
}