Files
Agent-Native/runtime/durable_test.go
T
weichen 5abb231efe
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Repair orphan tool-call tails on resume and stop mutating memory on Stop.Replacement (N3 WP1)
Why: a kill between persist(assistant) and commitToolIntent left an
unpaired tail that no recovery path repaired (acceptance clause 1's
library gap), and Stop.Replacement mutated the in-memory assistant
while the transcript kept the original (audit N6 residual).
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestResumePairsOrphanTailAfterCrashWindow (no-intent window pairs with
crashInterrupted, run continues), TestResumeMixedIntentAndOrphanTail
(intent path and tail repair never double-pair),
TestRepairOrphanTailNoopOnHealthyTranscript; the harness assertion now
pins replacement-as-user-note (forged=4, notes=3).
Impact: every next Run repairs the trailing assistant's resultless
calls before the first request; replacement text now appears only as a
user message in both memory and transcript (intentional, recorded in
the N3 card).
Refs: Agent-System-Documents#43 (N3 plan WP1)
2026-10-07 15:02:16 +00:00

365 lines
11 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 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))
}
}