Files
Agent-Native/runtime/durable_test.go
weichen e2be33f008
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add JournalOnly sessions and persist one-time history seeds (N3 WP2)
Why: the product conversation path needs the intent journal and
recovery pairing, but making the transcript the authoritative context
would freeze the assembled system prefix (memory/plans/injections go
stale). JournalOnly keeps the session as journal-only: recovery runs
against it, the context always comes from History, and the recovered
outcomes are injected as a system note. Separately, the one-time
History seed was memory-only and silently lost from the second run on
(authority mode); it is now persisted except in JournalOnly mode.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestJournalOnlyRecoversWithoutTranscriptAuthority (journal gets the
replayed pair exactly once, history never enters the journal, the
recovery note precedes the new user message, seeds stay out);
persistedProjection honors JournalOnly; all pre-existing durable and
harness tests pass unchanged (default semantics untouched).
Impact: new opt-in Config.JournalOnly; default (false) behavior is
byte-identical.
Refs: Agent-System-Documents#43 (N3 plan WP2)
2026-10-07 15:21:48 +00:00

443 lines
14 KiB
Go
Raw Permalink 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 runtime
import (
"context"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
type replayTool struct {
countingTool
replay string
}
func (r *replayTool) Replay() string {
if r.replay == "" {
return tools.ReplayNever
}
return r.replay
}
func TestPendingUnsafeToolIsNotRerun(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "write_file", Arguments: `{}`}},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "write_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
t.Fatal(err)
}
tool := &countingTool{name: "write_file"}
reg := tools.NewRegistry(dir)
reg.Register(tool)
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "next"})
if err != nil {
t.Fatal(err)
}
if tool.calls != 0 {
t.Fatalf("unsafe tool reran: %d", tool.calls)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
var paired bool
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == unknownSideEffect {
paired = true
}
}
if !paired {
t.Fatalf("pair missing: %#v", msgs)
}
}
func TestPendingSafeToolRerunsOnce(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe}
reg := tools.NewRegistry(dir)
reg.Register(tool)
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "next"})
if err != nil {
t.Fatal(err)
}
if tool.calls != 1 {
t.Fatalf("calls %d", tool.calls)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" {
if m.Content != "ok" || strings.Contains(m.Content, unknownSideEffect) {
t.Fatalf("result %#v", m)
}
return
}
}
t.Fatal("missing tool result")
}
func TestStoredSafeCurrentNeverDoesNotRerun(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplayNever}
reg := tools.NewRegistry(dir)
reg.Register(tool)
if _, err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
t.Fatal(err)
}
if tool.calls != 0 {
t.Fatalf("calls %d", tool.calls)
}
}
func TestFollowUpIsPersistedBeforeNextRequest(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
sent := false
var saw bool
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
go func() {
defer close(ch)
for _, m := range messages {
if m.Content == "follow-please" {
loaded, err := st.Load()
if err != nil {
t.Errorf("load %v", err)
}
for _, got := range loaded {
if got.Content == "follow-please" {
saw = true
}
}
}
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
}()
return ch
}
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, MaxTurns: 3, Stream: stream,
GetFollowUp: func() []llm.Message {
if sent {
return nil
}
sent = true
return []llm.Message{{Role: llm.RoleUser, Content: "follow-please"}}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if !saw {
t.Fatal("follow-up entered the request before it was stored")
}
}
func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
var names []string
var leaked bool
reg := tools.NewRegistry(t.TempDir())
reg.Register(&countingTool{name: "read_file"})
_, err := Run(context.Background(), Config{
Model: "fake", Tools: reg, MaxTurns: 2,
Trace: func(name string, attrs map[string]string) func() {
names = append(names, name)
if _, ok := attrs["prompt"]; ok || attrs["content"] != "" {
leaked = true
}
return func() {}
},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "1", Name: "read_file", Arguments: `{}`}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "用户的秘密提示词"})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(names, ",")
for _, want := range []string{"invoke_agent", "chat", "execute_tool"} {
if !strings.Contains(joined, want) {
t.Fatalf("names %s", joined)
}
}
if leaked {
t.Fatal("prompt leaked into span attrs")
}
}
// ---- N3 WP1:崩溃窗口尾部配对兜底(验收① 库层机器核心)----
// 窗口内死亡(persist(assistant) 之后、任何 intent 之前):转录只有带
// tool_calls 的 assistant。恢复后每个调用都有「进程中断」配对,且 run 继续。
func TestResumePairsOrphanTailAfterCrashWindow(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleSystem, Content: "sys"}); err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleUser, Content: "干个活"}); err != nil {
t.Fatal(err)
}
// 崩溃窗口形态:assistant 落盘、零意图、零结果。
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{
{ID: "c1", Name: "write_file", Arguments: `{}`},
{ID: "c2", Name: "read_file", Arguments: `{}`},
},
}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
tool := &countingTool{name: "write_file"}
reg.Register(tool)
reg.Register(&countingTool{name: "read_file"})
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "还在吗"})
if err != nil {
t.Fatalf("恢复后的 Run 不得失败(配对修复应让提供方接受历史):%v", err)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
// 双向配对断言:每个调用有结果、每个结果有调用。
calls, results := map[string]string{}, map[string]bool{}
for _, m := range msgs {
for _, c := range m.ToolCalls {
calls[c.ID] = c.Name
}
if m.Role == llm.RoleTool && m.ToolCallID != "" {
results[m.ToolCallID] = true
if m.Content != crashInterrupted && m.Content != unknownSideEffect {
t.Fatalf("兜底文案不对:%q", m.Content)
}
if !m.IsError {
t.Fatal("兜底结果必须 IsError")
}
}
}
for id := range calls {
if !results[id] {
t.Fatalf("孤立 tool_call:%s", id)
}
}
if tool.calls != 0 {
t.Fatalf("兜底不得重放写工具:%d", tool.calls)
}
}
// 执行中死亡(c1 意图+结果、c2 意图无结果)+ 窗口死亡(c3 无意图)混合:
// 意图路径配 c2、尾部兜底配 c3,互不重复。
func TestResumeMixedIntentAndOrphanTail(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{}`},
{ID: "c2", Name: "read_file", Arguments: `{}`},
{ID: "c3", Name: "write_file", Arguments: `{}`},
},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "真实结果"}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c2", Name: "read_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
reg.Register(&countingTool{name: "read_file"})
reg.Register(&countingTool{name: "write_file"})
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 1,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "next"})
if err != nil {
t.Fatal(err)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
byID := map[string][]string{}
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID != "" {
byID[m.ToolCallID] = append(byID[m.ToolCallID], m.Content)
}
}
if got := byID["c1"]; len(got) != 1 || got[0] != "真实结果" {
t.Fatalf("c1 必须保持原结果且不重复:%v", got)
}
if got := byID["c2"]; len(got) != 1 || got[0] != unknownSideEffect {
t.Fatalf("c2 走意图路径:%v", got)
}
if got := byID["c3"]; len(got) != 1 || got[0] != crashInterrupted {
t.Fatalf("c3 走尾部兜底:%v", got)
}
}
// 健康转录(尾部是完整配对或 user 消息)不做任何改写。
func TestRepairOrphanTailNoopOnHealthyTranscript(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, m := range []llm.Message{
{Role: llm.RoleSystem, Content: "sys"},
{Role: llm.RoleUser, Content: "u"},
{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "r"},
{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
{Role: llm.RoleUser, Content: "u2"},
} {
if err := st.Append(m); err != nil {
t.Fatal(err)
}
}
before, _ := st.Load()
if _, err := repairOrphanTail(Config{Session: st}); err != nil {
t.Fatal(err)
}
after, _ := st.Load()
if len(after) != len(before) {
t.Fatalf("健康转录被改写:%d → %d", len(before), len(after))
}
}
// ---- N3 WP2:JournalOnly(journal 只作日志,上下文由 History 组装)----
// JournalOnly:journal 有杀窗口尾巴 → 恢复配对写入 journal;上下文来自
// History(不含 journal 内容),恢复摘要以系统消息注入;种子不进 journal。
func TestJournalOnlyRecoversWithoutTranscriptAuthority(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
reg.Register(&replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe})
var gotReq []llm.Message
res, err := Run(context.Background(), Config{
Model: "fake", Session: st, JournalOnly: true, Tools: reg, MaxTurns: 2,
History: []llm.Message{
{Role: llm.RoleSystem, Content: "sys"},
{Role: llm.RoleUser, Content: "历史一"},
{Role: llm.RoleAssistant, Content: "回复一"},
},
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
gotReq = append([]llm.Message{}, msgs...)
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "最新"})
if err != nil {
t.Fatal(err)
}
// journal 里 c1 被 safe 重放配对(一次真实结果)。
journal, _ := st.Load()
var journaled bool
for _, m := range journal {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == "ok" {
journaled = true
}
if m.Content == "历史一" {
t.Fatal("种子历史不得进 journal")
}
}
if !journaled {
t.Fatalf("journal 缺恢复配对:%#v", journal)
}
// 上下文:History 内容在;恢复摘要在;且在新 user 消息之前。
var sawHist, sawNote int
noteIdx, userIdx := -1, -1
for i, m := range gotReq {
if m.Content == "历史一" {
sawHist++
}
if m.Role == llm.RoleSystem && strings.Contains(m.Content, "已完成恢复") && strings.Contains(m.Content, "c1") {
sawNote++
noteIdx = i
}
if m.Content == "最新" {
userIdx = i
}
}
if sawHist != 1 || sawNote != 1 {
t.Fatalf("历史=%d 摘要=%d:%#v", sawHist, sawNote, gotReq)
}
if noteIdx < 0 || userIdx < 0 || noteIdx > userIdx {
t.Fatalf("摘要必须在新 user 消息之前:note=%d user=%d", noteIdx, userIdx)
}
_ = res
}