Why: a crash after a tool was about to run left the call unpaired, and workspace writes could not be rewound. Evidence: GOWORK=off go test ./runtime/ ./runtime/llm/ ./runtime/checkpoint/ ./runtime/telemetry/ ./runtime/tools/ ./runtime/session/ ./adapters/native/ Impact: pending calls rerun only when both the stored and current replay declarations are safe; otherwise the result is an error. Empty fallback lists and a nil checkpoint log keep the previous behavior. Refs: feature/durable-rewind Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
5.8 KiB
Go
211 lines
5.8 KiB
Go
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")
|
|
}
|
|
}
|