Files
Agent-System-Backend/internal/runtime/llm_verify_test.go
T
weichenandCursor a3ae1b986d Check final replies before the direct run stops
Why: A direct run could stop on a missing file, a failed lint, or an unverified claim. The final turn now checks registered artifacts and asks a tool-less verifier, and nudges stale plans and todos.
Evidence: go test ./... passed. New runtime tests cover missing and empty artifacts, docx lint errors, verifier reject-then-pass, unparseable and failed verifier calls, nil verifier tools, todo reminders, and the false-deliverable fixture.
Impact: Direct runs with runtime.verify (default on for new runs) spend one extra lightweight call on every final turn. Setting it off keeps the previous stop behavior. Squad and SubLLMRunner are unchanged. The agent-native pin stays a34689c.
Refs: feature/agent-harness-p2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:54:15 +00:00

414 lines
13 KiB
Go

package runtime
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func textDeltaTurn(body string, calls ...agentllm.ToolCall) []agentllm.Event {
evs := []agentllm.Event{}
if body != "" {
evs = append(evs, agentllm.Event{Kind: agentllm.KindTextDelta, Text: body})
}
end := agentllm.Event{Kind: agentllm.KindMessageEnd}
if len(calls) > 0 {
end.Message.ToolCalls = calls
}
return append(evs, end)
}
func toolCall(name, args string) agentllm.ToolCall {
return agentllm.ToolCall{ID: "c-" + name, Name: name, Arguments: args}
}
func passedStream(payloads ...string) agentllm.StreamFunc {
n := 0
return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
body := `{"passed":true,"reason":"完成","nextAction":""}`
if n < len(payloads) {
body = payloads[n]
n++
}
out := make(chan agentllm.Event, 2)
out <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: body}
out <- agentllm.Event{Kind: agentllm.KindMessageEnd}
close(out)
return out
}
}
func verifyOn() core.AgentProfile {
return core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}}
}
func TestCheckMissingOrEmptyArtifactContinues(t *testing.T) {
for _, tc := range []struct {
name string
miss bool
want string
}{
{name: "missing", miss: true, want: "不存在"},
{name: "empty", miss: false, want: "内容为空"},
} {
t.Run(tc.name, func(t *testing.T) {
turns := padTurns(
textDeltaTurn("", toolCall("make_report", "{}")),
textDeltaTurn("写好了。"),
)
var calls int
base := scriptStream(turns, nil)
r := &LLMRunner{
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
calls++
return base(ctx, model, msgs, tools, opts)
},
VerifyStream: passedStream(),
}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: verifyOn(),
verify: true,
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "note.md"},
missingArtifact: tc.miss,
emptyArtifact: !tc.miss,
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if calls < 2 {
t.Fatalf("stream calls = %d, want a continuation", calls)
}
if !hasProcessNote(rc.assistant, tc.want) {
t.Fatalf("missing reason part %q in %+v", tc.want, textParts(rc.assistant))
}
})
}
}
func TestLintErrorContinues(t *testing.T) {
style := filepath.Join("..", "skills", "recipes", "house-style")
abs, err := filepath.Abs(style)
if err != nil {
t.Fatal(err)
}
t.Setenv("PYTHONPATH", abs)
docx := badDocx(t)
turns := padTurns(
textDeltaTurn("", toolCall("make_report", "{}")),
textDeltaTurn("写好了。"),
)
var verifyCalls int
base := scriptStream(turns, nil)
r := &LLMRunner{
Stream: base,
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
verifyCalls++
return passedStream()(ctx, model, msgs, tools, opts)
},
}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: verifyOn(),
verify: true,
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "note.docx"},
execArtifactContent: docx,
}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if verifyCalls != 0 {
t.Fatalf("verifier calls = %d, want 0 when lint fails first", verifyCalls)
}
if !hasProcessNote(rc.assistant, "版式") {
t.Fatalf("lint reason missing: %+v", textParts(rc.assistant))
}
}
func TestVerifierRejectsThenPasses(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("答案是 42。"),
textDeltaTurn("答案仍是 42。"),
}
var mainCalls int
var models []string
var toolArgs [][]agentllm.ToolDecl
base := scriptStream(turns, nil)
r := &LLMRunner{
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
mainCalls++
return base(ctx, model, msgs, tools, opts)
},
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
models = append(models, model)
toolArgs = append(toolArgs, tools)
body := `{"passed":false,"reason":"还没回答完","nextAction":"补上结论"}`
if len(models) > 1 {
body = `{"passed":true,"reason":"完成","nextAction":""}`
}
return passedStream(body)(ctx, model, msgs, tools, opts)
},
}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if mainCalls != 2 {
t.Fatalf("main calls = %d, want 2", mainCalls)
}
if len(models) != 2 || models[0] != "lite-model" {
t.Fatalf("verifier models = %v, want lite-model twice", models)
}
for i, tools := range toolArgs {
if tools != nil {
t.Fatalf("verifier call %d tools = %#v, want nil", i, tools)
}
}
texts := textParts(rc.assistant)
if len(texts) < 3 || !texts[0].Process || !strings.Contains(texts[1].Text, "验证未通过") || texts[len(texts)-1].Process {
t.Fatalf("parts = %+v", texts)
}
if !strings.Contains(texts[1].Text, "补上结论") {
t.Fatalf("nextAction missing: %+v", texts)
}
}
func TestVerifierGarbageFailsClosed(t *testing.T) {
for _, payload := range []agentllm.Event{
{Kind: agentllm.KindTextDelta, Text: "当然通过"},
{Kind: agentllm.KindError, ErrorMessage: "upstream"},
} {
t.Run(string(payload.Kind), func(t *testing.T) {
turns := [][]agentllm.Event{textDeltaTurn("答案是 42。")}
var mainCalls int
base := scriptStream(turns, nil)
r := &LLMRunner{
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
mainCalls++
return base(ctx, model, msgs, tools, opts)
},
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
if tools != nil {
t.Errorf("verifier tools = %#v, want nil", tools)
}
out := make(chan agentllm.Event, 2)
out <- payload
out <- agentllm.Event{Kind: agentllm.KindMessageEnd}
close(out)
return out
},
}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r.Execute(rc); err != nil {
t.Fatalf("Execute: %v", err)
}
if mainCalls != 1 {
t.Fatalf("main calls = %d, want 1", mainCalls)
}
texts := textParts(rc.assistant)
var kept, notice bool
for _, p := range texts {
if p.Text == "答案是 42。" && !p.Process {
kept = true
}
if !p.Process && strings.Contains(p.Text, "验证器不可用") {
notice = true
}
}
if !kept || !notice || !hasProcessNote(rc.assistant, "无法解析") && !hasProcessNote(rc.assistant, "调用失败") {
t.Fatalf("parts = %+v", texts)
}
})
}
}
func TestParseVerdictToleratesFence(t *testing.T) {
got, err := parseVerdict("```json\n{\"passed\":false,\"reason\":\"不够\",\"nextAction\":\"重写\"}\n```")
if err != nil || got.Passed || got.Reason != "不够" {
t.Fatalf("verdict = %+v %v", got, err)
}
if _, err := parseVerdict(`{"reason":"no passed"}`); err == nil {
t.Fatal("missing passed should fail")
}
}
type checklistFake struct {
fakeRunCtx
list core.TodoList
}
func (c *checklistFake) Todos() (core.TodoList, bool) { return c.list, true }
func padTurns(turns ...[]agentllm.Event) [][]agentllm.Event {
for len(turns) < 5 {
turns = append(turns, textDeltaTurn("仍未修好。"))
}
return turns
}
func TestTodoReminderFiresOnceAndResets(t *testing.T) {
quiet := textDeltaTurn("", toolCall("make_report", "{}"))
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("make_report", "{}")),
textDeltaTurn("", toolCall("make_report", "{}")),
textDeltaTurn("", toolCall("make_report", "{}")),
textDeltaTurn("完成了。"),
}
requests := runChecklist(t, turns, core.TodoList{Items: []core.TodoItem{{ID: "1", Text: "写纪要", Status: "pending"}}})
if len(requests) != 4 {
t.Fatalf("requests = %d, want 4", len(requests))
}
for i, req := range requests[:3] {
if strings.Contains(req, "system-reminder") {
t.Fatalf("request %d reminded too early: %s", i, req)
}
}
if !strings.Contains(requests[3], "system-reminder") || !strings.Contains(requests[3], "写纪要") {
t.Fatalf("final request missing reminder: %s", requests[3])
}
update := `{"items":[{"id":"1","text":"改后的待办","status":"pending"}]}`
resetTurns := [][]agentllm.Event{
textDeltaTurn("", toolCall("update_todos", update)),
quiet, quiet, quiet,
textDeltaTurn("完成了。"),
}
requests = runChecklist(t, resetTurns, core.TodoList{Items: []core.TodoItem{{ID: "1", Text: "旧待办", Status: "pending"}}})
if len(requests) != 5 {
t.Fatalf("reset requests = %d, want 5", len(requests))
}
for i, req := range requests[:4] {
if strings.Contains(req, "system-reminder") {
t.Fatalf("request %d reminded before three quiet turns: %s", i, req)
}
}
if !strings.Contains(requests[4], "改后的待办") || strings.Contains(requests[4], "旧待办") {
t.Fatalf("reminder did not refresh todos: %s", requests[4])
}
}
func TestTodoReminderCapsAtThree(t *testing.T) {
var turns [][]agentllm.Event
for i := 0; i < 12; i++ {
turns = append(turns, textDeltaTurn("", toolCall("make_report", "{}")))
}
turns = append(turns, textDeltaTurn("完成了。"))
requests := runChecklist(t, turns, core.TodoList{Items: []core.TodoItem{{ID: "1", Text: "写纪要", Status: "pending"}}})
n := 0
for _, req := range requests {
if strings.Contains(req, "system-reminder") {
n++
}
}
if n != 3 {
t.Fatalf("reminders = %d, want 3", n)
}
}
func runChecklist(t *testing.T, turns [][]agentllm.Event, list core.TodoList) []string {
t.Helper()
var requests []string
base := scriptStream(turns, nil)
r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
requests = append(requests, joinContents(msgs))
return base(ctx, model, msgs, tools, opts)
}}
rc := &checklistFake{
fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: verifyOn()},
list: list,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
for _, p := range rc.assistant {
if strings.Contains(p.Text, "system-reminder") || strings.Contains(p.Text, "连续 3 个工具轮") {
t.Fatalf("reminder was persisted: %+v", p)
}
}
return requests
}
func joinContents(msgs []agentllm.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(m.Content)
b.WriteString("\n")
}
return b.String()
}
func hasProcessNote(parts []core.Part, fragment string) bool {
for _, p := range parts {
if p.Type == core.PartText && p.Process && strings.Contains(p.Text, "验证未通过") && strings.Contains(p.Text, fragment) {
return true
}
}
return false
}
func badDocx(t *testing.T) []byte {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "bad.docx")
cmd := exec.Command("python3", "-c", "from docx import Document\nd=Document()\nd.add_paragraph('x')\nd.save("+jsonString(path)+")")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("docx: %v %s", err, out)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func TestFalseDeliverableFixtures(t *testing.T) {
raw, err := os.ReadFile("testdata/false_deliverables.json")
if err != nil {
t.Fatal(err)
}
var doc struct {
Cases []struct {
Name string `json:"name"`
Body string `json:"body"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
if len(doc.Cases) < 5 {
t.Fatalf("cases = %d, want at least 5", len(doc.Cases))
}
for _, c := range doc.Cases {
t.Run(c.Name, func(t *testing.T) {
block, reason := deliverableGate(c.Body, nil, &fakeRunCtx{ctx: context.Background()})
if !block || strings.TrimSpace(reason) == "" {
t.Fatalf("gate = %v %q", block, reason)
}
turns := [][]agentllm.Event{
textDeltaTurn(c.Body),
textDeltaTurn("这一轮没有生成文件。"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil), VerifyStream: passedStream()}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if !hasProcessNote(rc.assistant, reason) {
t.Fatalf("reason part missing %q in %+v", reason, textParts(rc.assistant))
}
})
}
}