187 lines
5.1 KiB
Go
187 lines
5.1 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"agent-native/runtime/llm"
|
|
"agent-native/runtime/session"
|
|
"agent-native/runtime/tools"
|
|
)
|
|
|
|
func script(turns ...llm.Message) llm.StreamFunc {
|
|
var mu sync.Mutex
|
|
i := 0
|
|
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
|
ch := make(chan llm.Event, 4)
|
|
go func() {
|
|
defer close(ch)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if i >= len(turns) {
|
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
|
return
|
|
}
|
|
msg := turns[i]
|
|
i++
|
|
if msg.Content != "" {
|
|
ch <- llm.Event{Kind: llm.KindTextDelta, Text: msg.Content}
|
|
}
|
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
|
|
}()
|
|
return ch
|
|
}
|
|
}
|
|
|
|
func TestLoopToolThenContinueAfterToolError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := tools.WorkspaceTools(dir)
|
|
res, err := Run(context.Background(), Config{
|
|
Model: "fake",
|
|
Tools: reg,
|
|
Stream: script(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "read_file", Arguments: `{"path":"missing.md"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "recovered", StopReason: llm.StopEnd},
|
|
),
|
|
}, llm.Message{Role: llm.RoleUser, Content: "read it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Text != "recovered" || res.StopReason != llm.StopEnd {
|
|
t.Fatalf("%#v", res)
|
|
}
|
|
sawToolErr := false
|
|
for _, m := range res.Messages {
|
|
if m.Role == llm.RoleTool && m.IsError {
|
|
sawToolErr = true
|
|
}
|
|
}
|
|
if !sawToolErr {
|
|
t.Fatal("expected tool error to continue the loop")
|
|
}
|
|
}
|
|
|
|
func TestLoopSteeringAndFollowUp(t *testing.T) {
|
|
steered := false
|
|
followed := false
|
|
res, err := Run(context.Background(), Config{
|
|
Model: "fake",
|
|
Stream: script(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "list_dir", Arguments: `{}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "after-steer", StopReason: llm.StopEnd},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "after-follow", StopReason: llm.StopEnd},
|
|
),
|
|
Tools: tools.WorkspaceTools(t.TempDir()),
|
|
GetSteering: func() []llm.Message {
|
|
if steered {
|
|
return nil
|
|
}
|
|
steered = true
|
|
return []llm.Message{{Role: llm.RoleUser, Content: "add a footer"}}
|
|
},
|
|
GetFollowUp: func() []llm.Message {
|
|
if followed {
|
|
return nil
|
|
}
|
|
followed = true
|
|
return []llm.Message{{Role: llm.RoleUser, Content: "one more"}}
|
|
},
|
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Text != "after-follow" {
|
|
t.Fatalf("text %q", res.Text)
|
|
}
|
|
joined := transcriptText(res.Messages)
|
|
if !strings.Contains(joined, "add a footer") || !strings.Contains(joined, "one more") {
|
|
t.Fatalf("transcript %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestLoopTranscriptReplay(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), ".agentx", "transcript.jsonl")
|
|
st, err := session.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = Run(context.Background(), Config{
|
|
Model: "fake",
|
|
SystemPrompt: "be brief",
|
|
Session: st,
|
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "pong", StopReason: llm.StopEnd}),
|
|
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded, err := st.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(loaded) != 3 || loaded[0].Role != llm.RoleSystem || loaded[1].Content != "ping" || loaded[2].Content != "pong" {
|
|
t.Fatalf("%#v", loaded)
|
|
}
|
|
again, err := session.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replay, err := again.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(replay) != len(loaded) || replay[2].Content != loaded[2].Content {
|
|
t.Fatal("replay diverged")
|
|
}
|
|
}
|
|
|
|
func TestLoopParallelReads(t *testing.T) {
|
|
dir := t.TempDir()
|
|
reg := tools.WorkspaceTools(dir)
|
|
if reg.Execute(context.Background(), "write_file", []byte(`{"path":"a.txt","content":"A"}`)).IsError {
|
|
t.Fatal("seed a")
|
|
}
|
|
if reg.Execute(context.Background(), "write_file", []byte(`{"path":"b.txt","content":"B"}`)).IsError {
|
|
t.Fatal("seed b")
|
|
}
|
|
res, err := Run(context.Background(), Config{
|
|
Model: "fake",
|
|
Tools: reg,
|
|
ToolExecution: ExecParallel,
|
|
Stream: script(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
|
{ID: "1", Name: "read_file", Arguments: `{"path":"a.txt"}`},
|
|
{ID: "2", Name: "read_file", Arguments: `{"path":"b.txt"}`},
|
|
}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "AB", StopReason: llm.StopEnd},
|
|
),
|
|
}, llm.Message{Role: llm.RoleUser, Content: "read both"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := map[string]string{}
|
|
for _, m := range res.Messages {
|
|
if m.Role == llm.RoleTool {
|
|
got[m.ToolCallID] = m.Content
|
|
}
|
|
}
|
|
if got["1"] != "A" || got["2"] != "B" {
|
|
t.Fatalf("parallel results %#v", got)
|
|
}
|
|
}
|
|
|
|
func transcriptText(messages []llm.Message) string {
|
|
var b strings.Builder
|
|
for _, m := range messages {
|
|
b.WriteString(m.Content)
|
|
b.WriteByte('\n')
|
|
}
|
|
return b.String()
|
|
}
|