Files
Agent-Native/runtime/loop_harness_test.go
T
weichen 6ddd3da8df Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:36:10 +00:00

372 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"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/llm"
"git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/session"
"git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/tools"
)
func TestLoopStopContinuesUntilCap(t *testing.T) {
var calls int
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
),
Stop: func(last llm.Message, info StopInfo) StopDecision {
calls++
if info.Continuations != calls-1 {
t.Errorf("continuations = %d, want %d", info.Continuations, calls-1)
}
return StopDecision{Continue: true, Replacement: "已作废", Reason: "再试", Notice: "到顶了"}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if calls != 4 {
t.Fatalf("stop calls = %d, want 4", calls)
}
if res.Err != nil || res.Notice != "到顶了" {
t.Fatalf("notice %q err %v", res.Notice, res.Err)
}
// N6(N3 WP1):assistant 不再被改写——替换文只以 user 消息出现,
// 内存与转录一致。
forged, notes := 0, 0
for _, m := range res.Messages {
if m.Role == llm.RoleAssistant && m.Content == "forged-claim" {
forged++
}
if m.Role == llm.RoleUser && m.Content == "已作废" {
notes++
}
}
if forged != 4 || notes != 3 {
t.Fatalf("forged=%d notes=%d transcript=%s", forged, notes, transcriptText(res.Messages))
}
if strings.Count(transcriptText(res.Messages), "再试") != 3 {
t.Fatalf("reasons = %s", transcriptText(res.Messages))
}
}
func TestLoopBeforeToolRejectPairsResult(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
var seen llm.Message
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: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
BeforeTool: func(call llm.ToolCall) error {
return errors.New("不允许")
},
AfterTool: func(call llm.ToolCall, msg llm.Message) {
seen = msg
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times", hits)
}
if !seen.IsError || seen.Content != "不允许" || seen.ToolCallID != "c1" {
t.Fatalf("after = %#v", seen)
}
paired := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "不允许" {
paired = true
}
}
if !paired {
t.Fatalf("missing paired tool result: %s", transcriptText(res.Messages))
}
}
func TestLoopRemindersStayOutOfSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "transcript.jsonl")
st, err := session.Open(path)
if err != nil {
t.Fatal(err)
}
var saw bool
_, err = Run(context.Background(), Config{
Model: "fake",
Session: st,
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if strings.Contains(m.Content, "<system-reminder>") && strings.Contains(m.Content, "先核对交付物") {
saw = true
}
}
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
Reminders: func(turn int) []string {
if turn != 0 {
t.Errorf("turn %d", turn)
}
return []string{"先核对交付物"}
},
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if !saw {
t.Fatal("reminder missing from the provider request")
}
loaded, err := st.Load()
if err != nil {
t.Fatal(err)
}
if strings.Contains(transcriptText(loaded), "先核对交付物") {
t.Fatalf("reminder persisted: %#v", loaded)
}
}
func TestLoopRepeatGuardFiresAndCaps(t *testing.T) {
var mu sync.Mutex
var requests []string
turns := make([]llm.Message, 0, 13)
for i := 0; i < 12; i++ {
args := `{"b":1,"a":2}`
if i%2 == 1 {
args = `{"a":2,"b":1}`
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c", Name: "echo", Arguments: args,
}}})
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd})
var i int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
mu.Lock()
requests = append(requests, transcriptText(messages))
n := i
i++
mu.Unlock()
ch := make(chan llm.Event, 1)
msg := turns[len(turns)-1]
if n < len(turns) {
msg = turns[n]
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 20,
RepeatGuard: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Text != "done" {
t.Fatalf("text %q", res.Text)
}
reminders := 0
for _, req := range requests {
reminders += strings.Count(req, repeatReminderText)
}
if reminders != 3 {
t.Fatalf("reminders = %d, want 3; requests=%d", reminders, len(requests))
}
if strings.Contains(transcriptText(res.Messages), repeatReminderText) {
t.Fatal("repeat reminder was written into the transcript")
}
if !strings.Contains(requests[3], repeatReminderText) {
t.Fatal("first reminder should arrive on the request after the third identical call")
}
}
func TestLoopContinueOnLengthCaps(t *testing.T) {
var requests []string
var n int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
requests = append(requests, transcriptText(messages))
n++
ch := make(chan llm.Event, 1)
ch <- llm.Event{
Kind: llm.KindMessageEnd,
FinishReason: "length",
Message: llm.Message{Role: llm.RoleAssistant, Content: "part", StopReason: llm.StopEnd},
}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
ContinueOnLength: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "length continuation exceeded") {
t.Fatalf("err = %v", res.Err)
}
if n != 4 {
t.Fatalf("model calls = %d, want 4", n)
}
if len(res.Messages) == 0 || !strings.Contains(transcriptText(res.Messages), "part") {
t.Fatal("truncated transcript was dropped")
}
if strings.Contains(transcriptText(res.Messages), lengthContinuePrompt) {
t.Fatal("continuation prompt was persisted")
}
prompts := 0
for _, req := range requests {
prompts += strings.Count(req, lengthContinuePrompt)
}
if prompts != 3 {
t.Fatalf("continuation prompts = %d, want 3", prompts)
}
}
func TestLoopContinueOnLengthUnlimited(t *testing.T) {
var n int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
n++
ch := make(chan llm.Event, 1)
finish := "length"
text := "part"
if n >= 5 {
finish = "stop"
text = "done"
}
ch <- llm.Event{
Kind: llm.KindMessageEnd,
FinishReason: finish,
Message: llm.Message{Role: llm.RoleAssistant, Content: text, StopReason: llm.StopEnd},
}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
ContinueOnLength: true,
MaxLengthContinuations: -1,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err != nil {
t.Fatalf("err = %v", res.Err)
}
if n != 5 {
t.Fatalf("model calls = %d, want 5", n)
}
if res.Text != "done" {
t.Fatalf("text = %q", res.Text)
}
}
func TestLoopHistorySkipsGeneratedSystem(t *testing.T) {
var first string
_, err := Run(context.Background(), Config{
Model: "fake",
SystemPrompt: "LEAK",
History: []llm.Message{
{Role: llm.RoleSystem, Content: "FROM_HISTORY"},
{Role: llm.RoleUser, Content: "earlier"},
},
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
if len(messages) > 0 {
first = messages[0].Content
}
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "now"})
if err != nil {
t.Fatal(err)
}
if first != "FROM_HISTORY" || strings.Contains(first, "LEAK") {
t.Fatalf("system = %q", first)
}
}
func TestLoopUnknownToolFallsThrough(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
UnknownTool: func(call llm.ToolCall) tools.Result {
return tools.Result{Content: "via " + call.Name}
},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "make_report", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
found := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.Content == "via make_report" && !m.IsError {
found = true
}
}
if !found {
t.Fatalf("unknown tool was not delegated: %s", transcriptText(res.Messages))
}
}
func TestLoopTurnCapKeepsTranscript(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 1,
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "missing", Arguments: `{}`,
}}}),
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "max turns exceeded") {
t.Fatalf("err = %v", res.Err)
}
if !strings.Contains(transcriptText(res.Messages), "ping") {
t.Fatalf("transcript dropped: %s", transcriptText(res.Messages))
}
}
type hitTool struct{ hits *int }
func (h hitTool) Name() string { return "echo" }
func (h hitTool) Description() string { return "echo" }
func (h hitTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (h hitTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
*h.hits++
return tools.Result{Content: "ran"}, nil
}