Files
Cyrene_v2/internal/runtime/loop/parallel_notice_test.go
T

284 lines
12 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"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 design/15 §2.5.6「上下文快照:第 2 路能看到第 1 路的什么」的落地回归。
//
// 它钉住四件事:
// - 单路(未挂 ctx 键)**零成本、逐字不变**:视图原样返回,不注入任何东西;
// - 并行时本路拿到的是**独立 system 消息**里的结构化元数据,且**不含**对方正文
// (结构里根本没有承载正文的字段 —— 这条红线是结构性的);
// - 提示**绝不进用户正文**(用户内容只进 user 角色消息是项目红线);
// - 提示不写回 msgs ⇒ 不进 checkpoint 快照(同一份 msgs 也是记忆提取的输入面)。
// testNoticeMarker 是进行态摘要的可辨认头标;testOtherLegBody 模拟「另一路」的半成品
// 正文,用来证明它绝不会漏进本路(§2.5.6「不做的事」①:不注入对方半成品正文)。
const (
testNoticeMarker = "[并行路提示]"
testOtherLegBody = "海洋覆盖了地球表面的七成,我想先从洋流的成因说起"
)
// TestParallelNoticeContextRoundTrip 钉住 ctx 通道本身:未挂载 / nil ctx 返回 ok=false,
// 挂载后读回同一份摘要,且**零值摘要也算已挂载**(头尾两句本身就是「另有一路在跑」的
// 核心信号,不能与「没挂」混为一谈)。
func TestParallelNoticeContextRoundTrip(t *testing.T) {
t.Parallel()
if _, ok := ParallelNoticeFrom(context.Background()); ok {
t.Fatal("未挂载的 ctx 不应报告有进行态摘要")
}
if _, ok := ParallelNoticeFrom(nil); ok {
t.Fatal("nil ctx 不应报告有进行态摘要")
}
want := ParallelNotice{
OtherMessageID: "msg-7",
Step: 3,
ToolNames: []string{"search_documents", "document-read"},
Queued: 1,
}
got, ok := ParallelNoticeFrom(WithParallelNotice(context.Background(), want))
if !ok {
t.Fatal("挂载后应报告有进行态摘要")
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("读回的摘要 = %+v, want %+v", got, want)
}
if _, ok := ParallelNoticeFrom(WithParallelNotice(context.Background(), ParallelNotice{})); !ok {
t.Fatal("零值摘要必须仍算已挂载(挂载即信号,与字段是否为空无关)")
}
}
// TestParallelNoticeTextVerbatim 把渲染结果与 design/15 §2.5.6 的原文逐字比对 ——
// 措辞是该设计的硬规格,任何「顺手改写」都应在此变红。
func TestParallelNoticeTextVerbatim(t *testing.T) {
t.Parallel()
got := parallelNoticeText(ParallelNotice{
OtherMessageID: "msg-9f3c",
Step: 3,
ToolNames: []string{"search_documents", "document-read"},
Queued: 1,
})
want := "[并行路提示] 该会话当前另有一路正在进行中(不打断它)。\n" +
"上一条消息 id:msg-9f3c;已进行到第 3 步;已调用工具:search_documents、document-read。\n" +
"另有 1 条消息在排队(未开始)。\n" +
"你这一路独立作答;不要复述或改写对方可能给出的内容。"
if got != want {
t.Fatalf("进行态摘要文本与 design/15 §2.5.6 逐字不符:\n got = %q\nwant = %q", got, want)
}
}
// TestParallelNoticeTextEmptyMetadataKeepsCore 钉住字段裁剪口径:空字段不渲染空段
// (不冒出「已进行到第 0 步」这类噪声),但头尾两句永远保留。
func TestParallelNoticeTextEmptyMetadataKeepsCore(t *testing.T) {
t.Parallel()
got := parallelNoticeText(ParallelNotice{})
if !strings.HasPrefix(got, parallelNoticeHeader) || !strings.HasSuffix(got, parallelNoticeTail) {
t.Fatalf("空元数据也必须保留头尾两句(核心信号): %q", got)
}
for _, noise := range []string{"上一条消息 id", "已进行到第", "在排队", "已调用工具"} {
if strings.Contains(got, noise) {
t.Fatalf("空字段不得渲染出空段 %q: %q", noise, got)
}
}
}
// TestAppendParallelNotice_NoKeyIsZeroCost 钉住单路(未挂 ctx 键)**零成本**:
// 视图长度不变,且返回的是**同一个底层数组的切片**(没有额外分配)——
// 「行为与今天逐字相同」的最强形式。
func TestAppendParallelNotice_NoKeyIsZeroCost(t *testing.T) {
t.Parallel()
l := New(Options{})
base := []model.Message{model.TextMessage(model.RoleUser, "hi")}
got := l.appendParallelNotice(context.Background(), "sess", 1, base)
if len(got) != len(base) {
t.Fatalf("未挂提示时视图长度变了: got %d, want %d", len(got), len(base))
}
if &got[0] != &base[0] {
t.Fatal("未挂提示时应原样返回同一底层数组的切片(零分配)")
}
}
// TestRun_ParallelNoticeIndependentSystemMessage 是核心用例:并行路在**每一步**都拿到
// 进行态摘要,形态是**独立的 system 消息**(恰好一条、内容与结构化渲染逐字相等),
// 且**绝不拼进用户正文**。内容等于 parallelNoticeText 这一断言同时钉死「只含元数据」:
// 任何往提示里塞正文的改动都会变红。
func TestRun_ParallelNoticeIndependentSystemMessage(t *testing.T) {
t.Parallel()
call := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "done"}},
}}
a := newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "echo", result: "hi"}))
l := New(Options{Agent: a, MaxRounds: 8})
notice := ParallelNotice{
OtherMessageID: "msg-ocean",
Step: 1,
ToolNames: []string{"search_documents"},
Queued: 1,
}
ctx := WithParallelNotice(context.Background(), notice)
userText := "写一篇 2000 字关于海洋的作文"
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, userText)}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.reqs) != 2 {
t.Fatalf("模型调用数 = %d, want 2(工具循环两步)", len(ad.reqs))
}
for i, req := range ad.reqs {
found := 0
for _, m := range req.Messages {
text := messageText(m)
if m.Role == model.RoleUser && strings.Contains(text, testNoticeMarker) {
t.Fatalf("step %d: 提示被拼进了用户正文(红线): %q", i+1, text)
}
if !strings.Contains(text, testNoticeMarker) {
continue
}
found++
if m.Role != model.RoleSystem {
t.Fatalf("step %d: 提示必须落在独立 system 消息上,实际 role=%q", i+1, m.Role)
}
if want := parallelNoticeText(notice); text != want {
t.Fatalf("step %d: 提示内容必须逐字等于结构化渲染(只含元数据):\n got = %q\nwant = %q", i+1, text, want)
}
}
if found != 1 {
t.Fatalf("step %d: 提示出现 %d 次, want 恰好 1 次", i+1, found)
}
}
}
// TestRun_NoParallelNoticeUnchanged 钉住单路(未挂 ctx 键)的视图与今天逐字相同:
// 模型请求里根本没有进行态摘要,用户消息保持原样。
func TestRun_NoParallelNoticeUnchanged(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "answer"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, MaxRounds: 8})
userText := "写一篇 2000 字关于海洋的作文"
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, userText)}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(ad.reqs) != 1 {
t.Fatalf("模型调用数 = %d, want 1", len(ad.reqs))
}
msgs := ad.reqs[0].Messages
if len(msgs) != 2 {
t.Fatalf("单路视图 = %d 条, want 2(agent 指令 + 用户消息,零新增)", len(msgs))
}
for _, m := range msgs {
if strings.Contains(messageText(m), testNoticeMarker) {
t.Fatalf("单路不得出现任何进行态摘要: %+v", m)
}
}
if got := messageText(msgs[1]); got != userText {
t.Fatalf("用户消息被改写: got %q, want %q", got, userText)
}
}
// TestRun_ParallelTwoLegsEachSeeOwnNotice 覆盖真实事故的形状:同会话两条路各自拿到
// **自己那份**摘要,且任一路都看不到**对方那一路**的消息内容(I4:运行中跨路不可见)——
// 正是「三路各自把同一句话当自己的题答」与「跨端串话」的回归。
func TestRun_ParallelTwoLegsEachSeeOwnNotice(t *testing.T) {
t.Parallel()
legAQuestion := "帮我写一篇 2000 字关于海洋的作文"
legBQuestion := "顺便查一下明天的天气"
// 两条路各自的摘要:A 被告知 B 在跑,B 被告知 A 在跑。
noticeA := ParallelNotice{OtherMessageID: "msg-b", Step: 1, ToolNames: []string{"search_documents"}}
noticeB := ParallelNotice{OtherMessageID: "msg-a", Step: 2, ToolNames: []string{"document-read"}}
runLeg := func(ctx context.Context, question string) []model.Message {
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "answer"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, MaxRounds: 8})
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, question)}); err != nil {
t.Fatalf("Run(%q): %v", question, err)
}
if len(ad.reqs) != 1 {
t.Fatalf("Run(%q) 模型调用数 = %d, want 1", question, len(ad.reqs))
}
return ad.reqs[0].Messages
}
msgsA := runLeg(WithParallelNotice(context.Background(), noticeA), legAQuestion)
msgsB := runLeg(WithParallelNotice(context.Background(), noticeB), legBQuestion)
assertNotice := func(name string, msgs []model.Message, want ParallelNotice, foreignQuestion string) {
t.Helper()
var texts []string
for _, m := range msgs {
texts = append(texts, messageText(m))
}
joined := strings.Join(texts, "\n")
if !strings.Contains(joined, parallelNoticeText(want)) {
t.Fatalf("%s: 未拿到自己那份摘要:\n got = %q\nwant 含 = %q", name, joined, parallelNoticeText(want))
}
// I4:本路的视图里不得出现对方那一路的用户消息内容。
if strings.Contains(joined, foreignQuestion) {
t.Fatalf("%s: 看到了对方那一路的消息内容(跨路串话): %q", name, foreignQuestion)
}
// 红线:提示里绝不出现对方半成品正文(结构上也无处可放)。
if strings.Contains(joined, testOtherLegBody) {
t.Fatalf("%s: 进行态摘要混入了对方正文", name)
}
}
assertNotice("leg-A", msgsA, noticeA, legBQuestion)
assertNotice("leg-B", msgsB, noticeB, legAQuestion)
}
// TestRun_ParallelNoticeNotInCheckpoint 钉住 §2.5.6 的「不写回 msgs」:进行态摘要
// 绝不进 checkpoint 快照。同一份 msgs 也是后台记忆提取的输入面,因此本用例同时覆盖
// 「不进记忆提取输入面」。
func TestRun_ParallelNoticeNotInCheckpoint(t *testing.T) {
t.Parallel()
store := newKeyedCheckpointStore()
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "answer"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{Agent: a, MaxRounds: 8, Checkpoint: store})
ctx := WithParallelNotice(context.Background(), ParallelNotice{
OtherMessageID: "msg-1", Step: 2, ToolNames: []string{"document-read"},
})
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(store.saved) == 0 {
t.Fatal("回合落定后应至少写一份 checkpoint")
}
for _, cp := range store.saved {
var st checkpointState
if err := json.Unmarshal(cp.State, &st); err != nil {
t.Fatalf("unmarshal checkpoint: %v", err)
}
for _, m := range st.Messages {
if strings.Contains(messageText(m), testNoticeMarker) {
t.Fatalf("进行态摘要不得进 checkpoint 快照: %+v", m)
}
}
}
}
// TestParallelNoticeToolNamesJoinedByFullWidthComma 钉住工具名分隔符与设计一致
// (全角「、」),避免后续改动悄悄换成半角。
func TestParallelNoticeToolNamesJoinedByFullWidthComma(t *testing.T) {
t.Parallel()
got := parallelNoticeFacts(ParallelNotice{ToolNames: []string{"a", "b", "c"}})
if got != "已调用工具:a、b、c。" {
t.Fatalf("事实行 = %q, want %q", got, "已调用工具:a、b、c。")
}
}