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 }