mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Recorded as candidates_failed before the run stops, so the trace says why. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
1076 lines
38 KiB
Go
1076 lines
38 KiB
Go
package runner
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/priyanshujain/sanderling/internal/driver"
|
|
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
|
"github.com/priyanshujain/sanderling/internal/llmclient"
|
|
"github.com/priyanshujain/sanderling/internal/trace"
|
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
|
)
|
|
|
|
// llmInputCorpus mirrors pkg/spec/src/corpus.ts INPUT_CORPUS so an InputText
|
|
// value drawn by the shared sampler can be asserted to come from the pool.
|
|
var llmInputCorpus = []string{
|
|
"", "a", strings.Repeat("a", 4096), "🙂🔥💸", " ", "\t\n", "-1",
|
|
"999999999999999999999", "0.0000001", "1e10", "'; DROP TABLE--",
|
|
"<script>alert(1)</script>", "../../etc/passwd", "%s%n", "NaN",
|
|
}
|
|
|
|
const llmFixtureSpec = `
|
|
import { llm, always, taps, typing, weighted } from "@sanderling/spec";
|
|
globalThis.properties = { ok: always(() => true) };
|
|
globalThis.actions = weighted([1, taps], [1, typing]);
|
|
globalThis.generator = llm({ model: "test/model" });
|
|
`
|
|
|
|
const llmTreeJSON = `{
|
|
"attributes": {"bounds": "[0,0,400,800]"},
|
|
"children": [
|
|
{"attributes": {"resource-id": "Submit", "text": "Submit", "bounds": "[0,0,400,100]"}, "clickable": true, "enabled": true, "children": []},
|
|
{"attributes": {"resource-id": "Name", "class": "EditText", "bounds": "[0,100,400,200]"}, "enabled": true, "children": []}
|
|
]
|
|
}`
|
|
|
|
func TestActionForCandidatePassesNonTypingThrough(t *testing.T) {
|
|
source := &llmSource{}
|
|
candidate := verifier.ActionCandidate{
|
|
Kind: verifier.ActionKindTap,
|
|
Action: verifier.Action{Kind: verifier.ActionKindTap, On: "id:Submit", X: 10, Y: 20},
|
|
}
|
|
action, err := source.actionForCandidate(candidate, "ignored")
|
|
if err != nil {
|
|
t.Fatalf("actionForCandidate: %v", err)
|
|
}
|
|
if action.Kind != verifier.ActionKindTap || action.On != "id:Submit" || action.X != 10 || action.Y != 20 {
|
|
t.Errorf("tap = %+v, want the candidate action verbatim", action)
|
|
}
|
|
if action.Text != "" {
|
|
t.Errorf("non-typing action must not carry text, got %q", action.Text)
|
|
}
|
|
}
|
|
|
|
func TestDescribeActionNamesGesturesByOrigin(t *testing.T) {
|
|
builtin := verifier.Action{
|
|
Kind: verifier.ActionKindScroll, Direction: "down",
|
|
FromX: 200, FromY: 500, ToX: 200, ToY: 340,
|
|
}
|
|
if got := describeAction(builtin); got != "Scroll down (200,500)" {
|
|
t.Errorf("builtin gesture described as %q, want the drag origin", got)
|
|
}
|
|
authored := verifier.Action{Kind: verifier.ActionKindScroll, Direction: "up", On: "id:List"}
|
|
if got := describeAction(authored); got != "Scroll up id:List" {
|
|
t.Errorf("authored scroll described as %q, want its selector", got)
|
|
}
|
|
}
|
|
|
|
func TestActionForCandidateUsesModelText(t *testing.T) {
|
|
source := &llmSource{}
|
|
candidate := verifier.ActionCandidate{
|
|
Kind: verifier.ActionKindInputText,
|
|
LLMText: true,
|
|
Action: verifier.Action{Kind: verifier.ActionKindInputText, On: "id:Name"},
|
|
}
|
|
action, err := source.actionForCandidate(candidate, "Priya")
|
|
if err != nil {
|
|
t.Fatalf("actionForCandidate: %v", err)
|
|
}
|
|
if action.Text != "Priya" {
|
|
t.Errorf("text = %q, want the model-supplied value", action.Text)
|
|
}
|
|
}
|
|
|
|
func TestActionForCandidateAuthoredTypingKeepsSampledValue(t *testing.T) {
|
|
source := &llmSource{}
|
|
candidate := verifier.ActionCandidate{
|
|
Kind: verifier.ActionKindInputText,
|
|
LLMText: false, // authored InputText: replay the spec's sampled value
|
|
Action: verifier.Action{Kind: verifier.ActionKindInputText, On: "id:Amount", Text: "42"},
|
|
}
|
|
action, err := source.actionForCandidate(candidate, "ignored")
|
|
if err != nil {
|
|
t.Fatalf("actionForCandidate: %v", err)
|
|
}
|
|
if action.Text != "42" {
|
|
t.Errorf("text = %q, want the authored value 42", action.Text)
|
|
}
|
|
}
|
|
|
|
func TestActionForCandidateFallsBackToSampler(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, _ := newLLMSource(t, fake)
|
|
candidate := verifier.ActionCandidate{
|
|
Kind: verifier.ActionKindInputText,
|
|
LLMText: true,
|
|
Action: verifier.Action{Kind: verifier.ActionKindInputText, On: "id:Name"},
|
|
}
|
|
action, err := source.actionForCandidate(candidate, " ")
|
|
if err != nil {
|
|
t.Fatalf("actionForCandidate: %v", err)
|
|
}
|
|
if !slices.Contains(llmInputCorpus, action.Text) {
|
|
t.Errorf("empty model text should fall back to the corpus, got %q", action.Text)
|
|
}
|
|
}
|
|
|
|
func TestParseChoice(t *testing.T) {
|
|
out, err := parseChoice(`{"reasoning":"go home","choice":3,"chosen_action":"Tap \"Home\"","text":""}`)
|
|
if err != nil {
|
|
t.Fatalf("parseChoice: %v", err)
|
|
}
|
|
if out.Reasoning != "go home" || out.Choice != 3 || out.ChosenAction != `Tap "Home"` {
|
|
t.Errorf("parseChoice = %+v", out)
|
|
}
|
|
|
|
if _, err := parseChoice(""); err == nil {
|
|
t.Error("expected error for empty content")
|
|
}
|
|
if _, err := parseChoice(`{"reasoning":"x","choice":0,"chosen_action":"","text":""}`); err == nil {
|
|
t.Error("expected error for a zero choice")
|
|
}
|
|
if _, err := parseChoice(`not json`); err == nil {
|
|
t.Error("expected error for malformed JSON")
|
|
}
|
|
}
|
|
|
|
func TestSystemPromptAppendsInstructions(t *testing.T) {
|
|
if got := (&llmSource{}).systemPrompt(); got != llmSystemPrompt {
|
|
t.Error("empty instructions should yield the base prompt unchanged")
|
|
}
|
|
withInstr := (&llmSource{instructions: "hunt for double submits"}).systemPrompt()
|
|
if !strings.Contains(withInstr, llmSystemPrompt) {
|
|
t.Error("system prompt must retain the base framing")
|
|
}
|
|
if !strings.Contains(withInstr, "hunt for double submits") {
|
|
t.Error("system prompt must include the spec instructions")
|
|
}
|
|
}
|
|
|
|
// fakeOpenRouter is a configurable in-process OpenRouter server. Set ranked /
|
|
// reasoning before each call; it echoes them as a json_schema content body.
|
|
type fakeOpenRouter struct {
|
|
server *httptest.Server
|
|
choice int
|
|
chosenAction string
|
|
text string
|
|
reasoning string
|
|
usage llmclient.Usage
|
|
servedModel string
|
|
delay time.Duration
|
|
lastRequest map[string]any
|
|
}
|
|
|
|
func newFakeOpenRouter(t *testing.T) *fakeOpenRouter {
|
|
t.Helper()
|
|
fake := &fakeOpenRouter{reasoning: "because"}
|
|
fake.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(body, &fake.lastRequest)
|
|
content, _ := json.Marshal(map[string]any{
|
|
"reasoning": fake.reasoning,
|
|
"choice": fake.choice,
|
|
"chosen_action": fake.chosenAction,
|
|
"text": fake.text,
|
|
})
|
|
response, _ := json.Marshal(llmclient.Response{
|
|
Model: fake.servedModel,
|
|
Choices: []llmclient.Choice{{Message: llmclient.ResponseMessage{Content: string(content)}}},
|
|
Usage: fake.usage,
|
|
})
|
|
time.Sleep(fake.delay)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(response)
|
|
}))
|
|
t.Cleanup(fake.server.Close)
|
|
return fake
|
|
}
|
|
|
|
// fakeCallRecorder keeps the per-step selection records in memory so a test can
|
|
// assert on them without opening the run directory.
|
|
type fakeCallRecorder struct {
|
|
calls []trace.LLMCall
|
|
}
|
|
|
|
func (r *fakeCallRecorder) WriteLLMCall(call trace.LLMCall) error {
|
|
r.calls = append(r.calls, call)
|
|
return nil
|
|
}
|
|
|
|
func recordedCalls(t *testing.T, source *llmSource) []trace.LLMCall {
|
|
t.Helper()
|
|
recorder, ok := source.recorder.(*fakeCallRecorder)
|
|
if !ok {
|
|
t.Fatalf("recorder = %T, want *fakeCallRecorder", source.recorder)
|
|
}
|
|
return recorder.calls
|
|
}
|
|
|
|
func lastCall(t *testing.T, source *llmSource) trace.LLMCall {
|
|
t.Helper()
|
|
calls := recordedCalls(t, source)
|
|
if len(calls) == 0 {
|
|
t.Fatal("no selection record written")
|
|
}
|
|
return calls[len(calls)-1]
|
|
}
|
|
|
|
// mustCandidates enumerates the model policy's list, failing the test on the
|
|
// refusal an authored multi-item sampler raises.
|
|
func mustCandidates(t *testing.T, v *verifier.Verifier, labelSource string) []verifier.ActionCandidate {
|
|
t.Helper()
|
|
candidates, err := v.Candidates(labelSource)
|
|
if err != nil {
|
|
t.Fatalf("Candidates: %v", err)
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func newLLMSource(t *testing.T, fake *fakeOpenRouter) (*llmSource, *verifier.Verifier) {
|
|
t.Helper()
|
|
return newLLMSourceWithSpec(t, fake, llmFixtureSpec)
|
|
}
|
|
|
|
func newLLMSourceWithSpec(t *testing.T, fake *fakeOpenRouter, spec string) (*llmSource, *verifier.Verifier) {
|
|
t.Helper()
|
|
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
|
t.Setenv("OPENROUTER_BASE_URL", fake.server.URL)
|
|
client, err := llmclient.New()
|
|
if err != nil {
|
|
t.Fatalf("llmclient.New: %v", err)
|
|
}
|
|
|
|
verifierInstance, err := verifier.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := verifierInstance.Load(bundleSpec(t, spec)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := verifierInstance.LLMConfig(); !ok {
|
|
t.Fatal("llm fixture spec did not register the llm action backend")
|
|
}
|
|
|
|
source := &llmSource{
|
|
verifier: verifierInstance,
|
|
client: client,
|
|
model: "test/model",
|
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
history: newActionHistory(llmHistorySize),
|
|
recorder: &fakeCallRecorder{},
|
|
}
|
|
return source, verifierInstance
|
|
}
|
|
|
|
func pushLLMSnapshot(t *testing.T, v *verifier.Verifier) {
|
|
t.Helper()
|
|
pushLLMSnapshotAtStep(t, v, 0)
|
|
}
|
|
|
|
func pushSnapshotTree(t *testing.T, v *verifier.Verifier, treeJSON string) {
|
|
t.Helper()
|
|
tree, err := hierarchy.Parse(treeJSON)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := v.PushSnapshot(verifier.SnapshotInput{
|
|
Tree: tree,
|
|
ScreenshotPNG: tinyPNG(t),
|
|
}); err != nil {
|
|
t.Fatalf("PushSnapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
func pushLLMSnapshotAtStep(t *testing.T, v *verifier.Verifier, stepIndex int) {
|
|
t.Helper()
|
|
tree, err := hierarchy.Parse(llmTreeJSON)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := v.PushSnapshot(verifier.SnapshotInput{
|
|
Tree: tree,
|
|
ScreenshotPNG: tinyPNG(t),
|
|
StepIndex: stepIndex,
|
|
}); err != nil {
|
|
t.Fatalf("PushSnapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
func readLLMCalls(t *testing.T, directory string) []trace.LLMCall {
|
|
t.Helper()
|
|
body, err := os.ReadFile(filepath.Join(directory, trace.LLMCallFileName))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", trace.LLMCallFileName, err)
|
|
}
|
|
var calls []trace.LLMCall
|
|
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
|
var call trace.LLMCall
|
|
if err := json.Unmarshal([]byte(line), &call); err != nil {
|
|
t.Fatalf("decode record %q: %v", line, err)
|
|
}
|
|
calls = append(calls, call)
|
|
}
|
|
return calls
|
|
}
|
|
|
|
func candidateByKind(t *testing.T, candidates []verifier.ActionCandidate, kind verifier.ActionKind) verifier.ActionCandidate {
|
|
t.Helper()
|
|
for _, candidate := range candidates {
|
|
if candidate.Kind == kind {
|
|
return candidate
|
|
}
|
|
}
|
|
t.Fatalf("no candidate of kind %q in %v", kind, candidates)
|
|
return verifier.ActionCandidate{}
|
|
}
|
|
|
|
func TestPickSourcesSelectsLLMWhenRequested(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
_, verifierInstance := newLLMSource(t, fake)
|
|
action, _, err := pickSources(Options{
|
|
Verifier: verifierInstance,
|
|
Generator: "llm",
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("pickSources: %v", err)
|
|
}
|
|
if _, ok := action.(*llmSource); !ok {
|
|
t.Errorf("action source = %T, want *llmSource for --generator llm", action)
|
|
}
|
|
}
|
|
|
|
func TestPickSourcesSeededByDefault(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
_, verifierInstance := newLLMSource(t, fake)
|
|
action, _, err := pickSources(Options{
|
|
Verifier: verifierInstance,
|
|
Generator: "seeded",
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("pickSources: %v", err)
|
|
}
|
|
// Even with a generator = llm() config present, the seeded flag wins.
|
|
if _, ok := action.(gojaSource); !ok {
|
|
t.Errorf("action source = %T, want gojaSource for --generator seeded", action)
|
|
}
|
|
}
|
|
|
|
// TestPickSourcesGivesTheLabelSourceToTheModelPickerOnly pins the asymmetry the
|
|
// labelling factorial depends on: the label channel reaches the model picker,
|
|
// and the seeded picker is handed a source that has nowhere to put one.
|
|
func TestPickSourcesGivesTheLabelSourceToTheModelPickerOnly(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
_, verifierInstance := newLLMSource(t, fake)
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
action, _, err := pickSources(Options{
|
|
Verifier: verifierInstance,
|
|
Generator: "llm",
|
|
LabelSource: verifier.LabelSourceResourceID,
|
|
Logger: logger,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("pickSources: %v", err)
|
|
}
|
|
model, ok := action.(*llmSource)
|
|
if !ok {
|
|
t.Fatalf("action source = %T, want *llmSource", action)
|
|
}
|
|
if model.labelSource != verifier.LabelSourceResourceID {
|
|
t.Errorf("labelSource = %q, want %q", model.labelSource, verifier.LabelSourceResourceID)
|
|
}
|
|
|
|
seeded, _, err := pickSources(Options{
|
|
Verifier: verifierInstance,
|
|
Generator: "seeded",
|
|
LabelSource: verifier.LabelSourceResourceID,
|
|
Logger: logger,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("pickSources: %v", err)
|
|
}
|
|
if _, ok := seeded.(gojaSource); !ok {
|
|
t.Errorf("action source = %T, want gojaSource, which carries no label channel", seeded)
|
|
}
|
|
}
|
|
|
|
// labelSplitTreeJSON names one control two ways, so a record can say which
|
|
// channel the model was reading.
|
|
const labelSplitTreeJSON = `{
|
|
"attributes": {"bounds": "[0,0,400,800]"},
|
|
"children": [
|
|
{"attributes": {"resource-id": "add_credit_button", "text": "Add credit", "bounds": "[0,0,400,100]"}, "clickable": true, "enabled": true, "children": []}
|
|
]
|
|
}`
|
|
|
|
// TestLLMSourceRecordsTheLabelsTheModelSaw closes the loop from the flag to the
|
|
// artifact: llm-calls.jsonl carries the candidate list as rendered, so a
|
|
// directory of runs can be checked against the cell it claims rather than
|
|
// trusted.
|
|
func TestLLMSourceRecordsTheLabelsTheModelSaw(t *testing.T) {
|
|
for _, want := range []struct{ labelSource, label string }{
|
|
{verifier.LabelSourceVisibleText, "Add credit"},
|
|
{verifier.LabelSourceResourceID, "add_credit_button"},
|
|
} {
|
|
t.Run(want.labelSource, func(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
source.labelSource = want.labelSource
|
|
pushSnapshotTree(t, verifierInstance, labelSplitTreeJSON)
|
|
|
|
tap := candidateByKind(t, mustCandidates(t, verifierInstance, want.labelSource), verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = tap.Description
|
|
if _, err := source.NextAction(context.Background(), 0); err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
|
|
call := lastCall(t, source)
|
|
if call.Outcome != trace.LLMOutcomeSelected {
|
|
t.Fatalf("outcome = %q, want selected", call.Outcome)
|
|
}
|
|
if len(call.Candidates) == 0 {
|
|
t.Fatal("no candidates recorded")
|
|
}
|
|
if call.Candidates[0].Label != want.label {
|
|
t.Errorf("recorded label = %q, want %q", call.Candidates[0].Label, want.label)
|
|
}
|
|
if !strings.Contains(call.UserPrompt, want.label) {
|
|
t.Errorf("prompt does not carry %q:\n%s", want.label, call.UserPrompt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// seededFixtureSpec declares no generator = llm(...), so --generator llm has
|
|
// nothing to build a picker from.
|
|
const seededFixtureSpec = `
|
|
import { always, taps, typing, weighted } from "@sanderling/spec";
|
|
globalThis.properties = { ok: always(() => true) };
|
|
globalThis.actions = weighted([1, taps], [1, typing]);
|
|
`
|
|
|
|
func newSeededVerifier(t *testing.T) *verifier.Verifier {
|
|
t.Helper()
|
|
verifierInstance, err := verifier.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := verifierInstance.Load(bundleSpec(t, seededFixtureSpec)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok := verifierInstance.LLMConfig(); ok {
|
|
t.Fatal("seeded fixture spec must not register an llm action backend")
|
|
}
|
|
return verifierInstance
|
|
}
|
|
|
|
// TestPickSourcesRejectsLLMWithoutSpecConfig pins the abort. Falling back to the
|
|
// seeded picker here completes the run, writes a well-formed output directory,
|
|
// and records it under the requested arm, so a campaign cell silently reports
|
|
// the wrong policy's numbers.
|
|
func TestPickSourcesRejectsLLMWithoutSpecConfig(t *testing.T) {
|
|
for name, activeDriver := range map[string]driver.DeviceDriver{
|
|
"native": nil,
|
|
"web": &webMockDriver{Driver: mockdriver.New()},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
action, extractor, err := pickSources(Options{
|
|
Driver: activeDriver,
|
|
Verifier: newSeededVerifier(t),
|
|
Generator: "llm",
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("pickSources = (%T, %T), want an error", action, extractor)
|
|
}
|
|
if action != nil || extractor != nil {
|
|
t.Errorf("sources = (%v, %v), want both nil alongside the error", action, extractor)
|
|
}
|
|
if !strings.Contains(err.Error(), "generator = llm(...)") {
|
|
t.Errorf("error = %q, want it to name the missing spec declaration", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPickSourcesOnWebComposesLLMWithWebExtractors covers the second half of the
|
|
// same claim: the picker is chosen by --generator and the extractor source by
|
|
// the driver, so the llm policy runs on web instead of being silently replaced
|
|
// by the V8 picker.
|
|
func TestPickSourcesOnWebComposesLLMWithWebExtractors(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
_, verifierInstance := newLLMSource(t, fake)
|
|
action, extractor, err := pickSources(Options{
|
|
Driver: &webMockDriver{Driver: mockdriver.New()},
|
|
Verifier: verifierInstance,
|
|
Generator: "llm",
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("pickSources: %v", err)
|
|
}
|
|
if _, ok := action.(*llmSource); !ok {
|
|
t.Errorf("action source = %T, want *llmSource on web with --generator llm", action)
|
|
}
|
|
if _, ok := extractor.(webSource); !ok {
|
|
t.Errorf("extractor source = %T, want webSource so overrides still come from V8", extractor)
|
|
}
|
|
}
|
|
|
|
func TestPickSourcesOnWebSeededKeepsBothOnV8(t *testing.T) {
|
|
action, extractor, err := pickSources(Options{
|
|
Driver: &webMockDriver{Driver: mockdriver.New()},
|
|
Verifier: newSeededVerifier(t),
|
|
Generator: "seeded",
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("pickSources: %v", err)
|
|
}
|
|
if _, ok := action.(webSource); !ok {
|
|
t.Errorf("action source = %T, want webSource for the seeded web path", action)
|
|
}
|
|
if _, ok := extractor.(webSource); !ok {
|
|
t.Errorf("extractor source = %T, want webSource for the seeded web path", extractor)
|
|
}
|
|
}
|
|
|
|
func TestLLMSourceDrivesExecutedActions(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
candidates := mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText)
|
|
|
|
// Step 1: the model picks the Tap on Submit by its number, echoing its
|
|
// description.
|
|
tap := candidateByKind(t, candidates, verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = tap.Description
|
|
fake.reasoning = "tap submit"
|
|
fake.text = ""
|
|
action, err := source.NextAction(context.Background(), 1)
|
|
if err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
if action.Kind != verifier.ActionKindTap || action.On != "id:Submit" {
|
|
t.Errorf("step 1 action = %+v, want Tap on id:Submit", action)
|
|
}
|
|
if source.lastSource != "llm" || source.lastReasoning != "tap submit" {
|
|
t.Errorf("source state = %q/%q, want llm/tap submit", source.lastSource, source.lastReasoning)
|
|
}
|
|
|
|
// The request carried the model and a screenshot image part.
|
|
if fake.lastRequest["model"] != "test/model" {
|
|
t.Errorf("request model = %v", fake.lastRequest["model"])
|
|
}
|
|
if !requestHasImage(fake.lastRequest) {
|
|
t.Error("request carried no screenshot image part")
|
|
}
|
|
|
|
// Step 2: the model picks the typing candidate and supplies the value.
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
typing := candidateByKind(t, candidates, verifier.ActionKindInputText)
|
|
fake.choice = typing.Index
|
|
fake.chosenAction = typing.Description
|
|
fake.reasoning = "type a name"
|
|
fake.text = "Priya"
|
|
action, err = source.NextAction(context.Background(), 1)
|
|
if err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
if action.Kind != verifier.ActionKindInputText || action.On != "id:Name" {
|
|
t.Errorf("step 2 action = %+v, want InputText on id:Name", action)
|
|
}
|
|
if action.Text != "Priya" {
|
|
t.Errorf("InputText text = %q, want the model-supplied Priya", action.Text)
|
|
}
|
|
|
|
// The trace records source=llm, the reasoning, the choice, and the echo.
|
|
traceAction := traceActionFor(action, nil)
|
|
stampActionSource(traceAction, source)
|
|
if traceAction.Source != "llm" || traceAction.LLMReasoning != "type a name" {
|
|
t.Errorf("trace action = %+v, want source=llm reasoning=type a name", traceAction)
|
|
}
|
|
if traceAction.LLMChoice != typing.Index || traceAction.LLMChosenAction != typing.Description {
|
|
t.Errorf("trace choice = %d/%q, want %d/%q", traceAction.LLMChoice, traceAction.LLMChosenAction, typing.Index, typing.Description)
|
|
}
|
|
}
|
|
|
|
func TestLLMSourceSkipsOnOutOfRangeChoice(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
|
|
fake.choice = 9999
|
|
fake.chosenAction = "whatever"
|
|
_, err := source.NextAction(context.Background(), 1)
|
|
if !errors.Is(err, verifier.ErrNoAction) {
|
|
t.Fatalf("NextAction err = %v, want ErrNoAction for an out-of-range choice", err)
|
|
}
|
|
if source.lastSource != "" {
|
|
t.Errorf("lastSource = %q, want empty after a skipped step", source.lastSource)
|
|
}
|
|
}
|
|
|
|
func TestLLMSourceAcceptsEchoWithWeightSuffix(t *testing.T) {
|
|
// Real models copy the whole numbered line, including its trailing "(w34)"
|
|
// weight annotation. That must still count as a match, not a strict skip.
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
candidates := mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText)
|
|
|
|
tap := candidateByKind(t, candidates, verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = tap.Description + " (w" + strconv.Itoa(tap.Weight) + ")"
|
|
action, err := source.NextAction(context.Background(), 1)
|
|
if err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
if action.Kind != verifier.ActionKindTap {
|
|
t.Errorf("action = %+v, want Tap; the weight-suffixed echo was wrongly rejected", action)
|
|
}
|
|
if source.lastSource != "llm" {
|
|
t.Error("weight-suffixed echo should be accepted, not strict-skipped")
|
|
}
|
|
}
|
|
|
|
func TestStripWeightSuffix(t *testing.T) {
|
|
cases := map[string]string{
|
|
`Tap "+ Add account" (w34)`: `Tap "+ Add account"`,
|
|
`Tap "Sign in"`: `Tap "Sign in"`,
|
|
`Scroll down (w7)`: `Scroll down`,
|
|
` Tap "x" (w1) `: `Tap "x"`,
|
|
}
|
|
for in, want := range cases {
|
|
if got := stripWeightSuffix(in); got != want {
|
|
t.Errorf("stripWeightSuffix(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLLMSourceStrictSkipsOnEchoMismatch(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
candidates := mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText)
|
|
|
|
// A valid number, but the echoed action disagrees with that numbered entry:
|
|
// the model reasoned about one control and picked another's number.
|
|
tap := candidateByKind(t, candidates, verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = "Tap \"Something Else\""
|
|
_, err := source.NextAction(context.Background(), 1)
|
|
if !errors.Is(err, verifier.ErrNoAction) {
|
|
t.Fatalf("NextAction err = %v, want ErrNoAction on chosen_action mismatch", err)
|
|
}
|
|
if source.lastSource != "" {
|
|
t.Errorf("lastSource = %q, want empty after a strict skip", source.lastSource)
|
|
}
|
|
}
|
|
|
|
// llmSharedLabelTreeJSON has two rows a user reads as the same word, so the
|
|
// numbered list holds two entries rendering identically.
|
|
const llmSharedLabelTreeJSON = `{
|
|
"attributes": {"bounds": "[0,0,400,800]"},
|
|
"children": [
|
|
{"attributes": {"resource-id": "delete_alpha", "text": "Delete", "bounds": "[0,0,400,100]"}, "clickable": true, "enabled": true, "children": []},
|
|
{"attributes": {"resource-id": "delete_beta", "text": "Delete", "bounds": "[0,100,400,200]"}, "clickable": true, "enabled": true, "children": []}
|
|
]
|
|
}`
|
|
|
|
// TestLLMSourceEchoGuardAdmitsARepeatedDescription is the other half of the
|
|
// strict skip: it compares the echo against the entry the model NUMBERED, so a
|
|
// description shared by two entries still selects the one whose number came
|
|
// back. A guard that looked the echo up by description instead would run the
|
|
// first row for both numbers.
|
|
func TestLLMSourceEchoGuardAdmitsARepeatedDescription(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushSnapshotTree(t, verifierInstance, llmSharedLabelTreeJSON)
|
|
|
|
var repeated []verifier.ActionCandidate
|
|
for _, candidate := range mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText) {
|
|
if candidate.Description == `Tap "Delete"` {
|
|
repeated = append(repeated, candidate)
|
|
}
|
|
}
|
|
if len(repeated) != 2 {
|
|
t.Fatalf("want two entries sharing one description, got %d", len(repeated))
|
|
}
|
|
|
|
second := repeated[1]
|
|
fake.choice = second.Index
|
|
fake.chosenAction = second.Description
|
|
action, err := source.NextAction(context.Background(), 1)
|
|
if err != nil {
|
|
t.Fatalf("NextAction err = %v, want the second row's tap", err)
|
|
}
|
|
if action.On != "id:delete_beta" {
|
|
t.Errorf("action targets %q, want id:delete_beta", action.On)
|
|
}
|
|
if source.lastSource != "llm" {
|
|
t.Errorf("lastSource = %q, want llm; a repeated description must not strict-skip", source.lastSource)
|
|
}
|
|
}
|
|
|
|
func TestLLMSourceSkipsOnHTTPError(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
// Replace the handler with one that always errors.
|
|
fake.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
|
|
fake.choice = 1
|
|
_, err := source.NextAction(context.Background(), 1)
|
|
if !errors.Is(err, verifier.ErrNoAction) {
|
|
t.Fatalf("NextAction err = %v, want ErrNoAction on HTTP failure", err)
|
|
}
|
|
}
|
|
|
|
// llmSetupFixtureSpec drives the first action from setup, so the model is never
|
|
// consulted for that step.
|
|
const llmSetupFixtureSpec = `
|
|
import { llm, always, actions, taps, typing, weighted, Tap } from "@sanderling/spec";
|
|
globalThis.properties = { ok: always(() => true) };
|
|
globalThis.setup = actions(() => [Tap({ on: "id:Submit" })]);
|
|
globalThis.actions = weighted([1, taps], [1, typing]);
|
|
globalThis.generator = llm({ model: "test/model" });
|
|
`
|
|
|
|
// TestLLMCallRecordSeparatesGuardSkipFromDecline pins the reason these records
|
|
// exist. A step the echo guard threw away and a step where the picker had
|
|
// nothing to choose both used to leave nothing behind but a log line, so any
|
|
// defect-yield or actions-per-hour figure computed from a model run silently
|
|
// mixed the two with each other and with steps that acted.
|
|
func TestLLMCallRecordSeparatesGuardSkipFromDecline(t *testing.T) {
|
|
const stepIndex = 7
|
|
|
|
guardSkipped := func(t *testing.T) trace.LLMCall {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
tap := candidateByKind(t, mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText), verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = `Tap "Something Else"`
|
|
if _, err := source.NextAction(context.Background(), stepIndex); !errors.Is(err, verifier.ErrNoAction) {
|
|
t.Fatalf("NextAction err = %v, want ErrNoAction on echo mismatch", err)
|
|
}
|
|
return lastCall(t, source)
|
|
}
|
|
declined := func(t *testing.T) trace.LLMCall {
|
|
fake := newFakeOpenRouter(t)
|
|
// No snapshot pushed, so the action tree yields nothing to pick from.
|
|
source, _ := newLLMSource(t, fake)
|
|
if _, err := source.NextAction(context.Background(), stepIndex); !errors.Is(err, verifier.ErrNoAction) {
|
|
t.Fatalf("NextAction err = %v, want ErrNoAction with no candidates", err)
|
|
}
|
|
return lastCall(t, source)
|
|
}
|
|
acted := func(t *testing.T) trace.LLMCall {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
tap := candidateByKind(t, mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText), verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = tap.Description
|
|
if _, err := source.NextAction(context.Background(), stepIndex); err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
return lastCall(t, source)
|
|
}
|
|
|
|
skip, decline, pick := guardSkipped(t), declined(t), acted(t)
|
|
if skip.Outcome != trace.LLMOutcomeEchoMismatch {
|
|
t.Errorf("guard-skipped outcome = %q, want %q", skip.Outcome, trace.LLMOutcomeEchoMismatch)
|
|
}
|
|
if decline.Outcome != trace.LLMOutcomeNoCandidates {
|
|
t.Errorf("declined outcome = %q, want %q", decline.Outcome, trace.LLMOutcomeNoCandidates)
|
|
}
|
|
if pick.Outcome != trace.LLMOutcomeSelected {
|
|
t.Errorf("executed outcome = %q, want %q", pick.Outcome, trace.LLMOutcomeSelected)
|
|
}
|
|
for _, call := range []trace.LLMCall{skip, decline, pick} {
|
|
if call.Step != stepIndex {
|
|
t.Errorf("record step = %d, want %d so it joins its trace line", call.Step, stepIndex)
|
|
}
|
|
if call.Timestamp.IsZero() {
|
|
t.Error("record carries no timestamp")
|
|
}
|
|
}
|
|
// The guard skip must carry what only the dropped log line used to hold.
|
|
if skip.Choice == 0 || skip.EchoedAction != `Tap "Something Else"` || skip.RawResponse == "" {
|
|
t.Errorf("guard-skip record = %+v, want the choice, the echo, and the raw response", skip)
|
|
}
|
|
if len(skip.Candidates) == 0 {
|
|
t.Error("guard-skip record must keep the candidate list the mismatch is judged against")
|
|
}
|
|
// A decline never reached the provider, so it must not look like a call.
|
|
if decline.RawResponse != "" || decline.UserPrompt != "" || len(decline.Candidates) != 0 {
|
|
t.Errorf("decline record = %+v, want no prompt, candidates, or response", decline)
|
|
}
|
|
}
|
|
|
|
// TestLLMCallRecordsCandidateListAsShown covers the companion experiment that
|
|
// varies how candidates are labelled: the labels are an independent variable, so
|
|
// each call must be recoverable with the exact numbered list it saw.
|
|
func TestLLMCallRecordsCandidateListAsShown(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
source.instructions = "hunt for double submits"
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
tap := candidateByKind(t, mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText), verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = tap.Description
|
|
if _, err := source.NextAction(context.Background(), 1); err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
|
|
call := lastCall(t, source)
|
|
if len(call.Candidates) == 0 {
|
|
t.Fatal("no candidate list recorded")
|
|
}
|
|
for _, candidate := range call.Candidates {
|
|
line := fmt.Sprintf("%d. %s", candidate.Index, candidate.Description)
|
|
if !strings.Contains(call.UserPrompt, line) {
|
|
t.Errorf("recorded candidate %q is not a line of the prompt:\n%s", line, call.UserPrompt)
|
|
}
|
|
if candidate.Weight > 0 && !strings.Contains(call.UserPrompt, fmt.Sprintf("%s (w%d)", candidate.Description, candidate.Weight)) {
|
|
t.Errorf("recorded weight %d for %q is not the weight the prompt showed:\n%s",
|
|
candidate.Weight, candidate.Description, call.UserPrompt)
|
|
}
|
|
}
|
|
numberedLine := regexp.MustCompile(`(?m)^\d+\. `)
|
|
if shown := len(numberedLine.FindAllString(call.UserPrompt, -1)); shown != len(call.Candidates) {
|
|
t.Errorf("prompt showed %d numbered lines but %d candidates were recorded", shown, len(call.Candidates))
|
|
}
|
|
if got := candidateLabels(call.Candidates); !slices.Contains(got, tap.Label) {
|
|
t.Errorf("recorded labels = %v, want the target label %q among them", got, tap.Label)
|
|
}
|
|
// The system prompt is assembled from a constant plus spec instructions, so
|
|
// the record has to be the assembled text, not the constant.
|
|
if call.SystemPrompt != source.systemPrompt() {
|
|
t.Errorf("recorded system prompt = %q, want the assembled prompt", call.SystemPrompt)
|
|
}
|
|
if !strings.Contains(call.SystemPrompt, "hunt for double submits") {
|
|
t.Error("recorded system prompt dropped the spec instructions")
|
|
}
|
|
if call.Model != "test/model" {
|
|
t.Errorf("recorded model = %q, want test/model", call.Model)
|
|
}
|
|
}
|
|
|
|
func candidateLabels(candidates []trace.LLMCandidate) []string {
|
|
labels := make([]string, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
labels = append(labels, candidate.Label)
|
|
}
|
|
return labels
|
|
}
|
|
|
|
func TestLLMCallRecordsSetupDrivenStep(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSourceWithSpec(t, fake, llmSetupFixtureSpec)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
|
|
action, err := source.NextAction(context.Background(), 2)
|
|
if err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
if action.On != "id:Submit" {
|
|
t.Fatalf("action = %+v, want the setup tap on id:Submit", action)
|
|
}
|
|
call := lastCall(t, source)
|
|
if call.Outcome != trace.LLMOutcomeSetupAction {
|
|
t.Errorf("outcome = %q, want %q", call.Outcome, trace.LLMOutcomeSetupAction)
|
|
}
|
|
if call.UserPrompt != "" || call.RawResponse != "" || call.TotalTokens != 0 {
|
|
t.Errorf("setup-driven step = %+v, want no model call recorded", call)
|
|
}
|
|
if source.lastSource != "" {
|
|
t.Errorf("lastSource = %q, want empty: setup chose the action, not the model", source.lastSource)
|
|
}
|
|
}
|
|
|
|
func TestLLMCallFileRecordsUsageLatencyAndScreenshot(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
fake.usage = llmclient.Usage{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234}
|
|
fake.servedModel = "vendor/model-2026-05"
|
|
fake.delay = 15 * time.Millisecond
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
|
|
directory := t.TempDir()
|
|
writer, err := trace.NewWriter(directory)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source.recorder = writer
|
|
|
|
pushLLMSnapshotAtStep(t, verifierInstance, 4)
|
|
tap := candidateByKind(t, mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText), verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = tap.Description
|
|
if _, err := source.NextAction(context.Background(), 4); err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
calls := readLLMCalls(t, directory)
|
|
if len(calls) != 1 {
|
|
t.Fatalf("recorded %d calls, want 1", len(calls))
|
|
}
|
|
call := calls[0]
|
|
if call.PromptTokens != 1200 || call.CompletionTokens != 34 || call.TotalTokens != 1234 {
|
|
t.Errorf("tokens = %d/%d/%d, want 1200/34/1234",
|
|
call.PromptTokens, call.CompletionTokens, call.TotalTokens)
|
|
}
|
|
if call.LatencyMillis < 15 {
|
|
t.Errorf("latency = %dms, want at least the server's 15ms", call.LatencyMillis)
|
|
}
|
|
if call.ServedModel != "vendor/model-2026-05" {
|
|
t.Errorf("served model = %q, want the id the provider reported", call.ServedModel)
|
|
}
|
|
if want := trace.ScreenshotReference(4); call.Screenshot != want {
|
|
t.Errorf("screenshot = %q, want %q", call.Screenshot, want)
|
|
}
|
|
if call.Reasoning == "" || call.EchoedAction != tap.Description {
|
|
t.Errorf("record = %+v, want the parsed reasoning and echo", call)
|
|
}
|
|
}
|
|
|
|
// TestLLMCallScreenshotNamesObservedStep guards the one case where the image
|
|
// sent is not the current step's: the runner skips PushSnapshot on a
|
|
// transitional observation, so the model still sees the last observed screen.
|
|
func TestLLMCallScreenshotNamesObservedStep(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSource(t, fake)
|
|
pushLLMSnapshotAtStep(t, verifierInstance, 4)
|
|
tap := candidateByKind(t, mustCandidates(t, verifierInstance, verifier.LabelSourceVisibleText), verifier.ActionKindTap)
|
|
fake.choice = tap.Index
|
|
fake.chosenAction = tap.Description
|
|
if _, err := source.NextAction(context.Background(), 6); err != nil {
|
|
t.Fatalf("NextAction: %v", err)
|
|
}
|
|
|
|
call := lastCall(t, source)
|
|
if call.Step != 6 {
|
|
t.Errorf("record step = %d, want the current step 6", call.Step)
|
|
}
|
|
if want := trace.ScreenshotReference(4); call.Screenshot != want {
|
|
t.Errorf("screenshot = %q, want %q: the image sent was step 4's", call.Screenshot, want)
|
|
}
|
|
}
|
|
|
|
func TestDownscalePNGShrinksLongEdge(t *testing.T) {
|
|
large := image.NewRGBA(image.Rect(0, 0, 2048, 1024))
|
|
var buffer bytes.Buffer
|
|
if err := png.Encode(&buffer, large); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
scaled := downscalePNG(buffer.Bytes(), 1024)
|
|
decoded, err := png.Decode(bytes.NewReader(scaled))
|
|
if err != nil {
|
|
t.Fatalf("decode scaled: %v", err)
|
|
}
|
|
if decoded.Bounds().Dx() != 1024 {
|
|
t.Errorf("scaled width = %d, want 1024", decoded.Bounds().Dx())
|
|
}
|
|
if decoded.Bounds().Dy() != 512 {
|
|
t.Errorf("scaled height = %d, want 512", decoded.Bounds().Dy())
|
|
}
|
|
}
|
|
|
|
func TestDownscalePNGKeepsSmallImage(t *testing.T) {
|
|
original := tinyPNG(t)
|
|
if got := downscalePNG(original, 1024); !bytes.Equal(got, original) {
|
|
t.Error("a sub-maxEdge image should be returned unchanged")
|
|
}
|
|
}
|
|
|
|
func requestHasImage(request map[string]any) bool {
|
|
messages, ok := request["messages"].([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, message := range messages {
|
|
parts, ok := message.(map[string]any)["content"].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, part := range parts {
|
|
if part.(map[string]any)["type"] == "image_url" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tinyPNG(t *testing.T) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
|
var buffer bytes.Buffer
|
|
if err := png.Encode(&buffer, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return buffer.Bytes()
|
|
}
|
|
|
|
// llmSamplerFixtureSpec drives the model policy over an authored leaf that
|
|
// samples one of three targets, which is the shape the seeded picker draws from
|
|
// and the model policy cannot.
|
|
const llmSamplerFixtureSpec = `
|
|
import { actions, from, llm, Tap, always } from "@sanderling/spec";
|
|
globalThis.properties = { ok: always(() => true) };
|
|
const targets = from(["id:Submit", "id:Name"]);
|
|
globalThis.actions = actions(() => [Tap({ on: targets.generate() })]);
|
|
globalThis.generator = llm({ model: "test/model" });
|
|
`
|
|
|
|
// TestLLMSourceRefusesAMultiItemAuthoredSampler: the step must fail the run, not
|
|
// skip. A skip would leave the model quietly fuzzing a spec whose authored
|
|
// targets it can never reach past the first, which is the comparison the seeded
|
|
// arm is measured against.
|
|
func TestLLMSourceRefusesAMultiItemAuthoredSampler(t *testing.T) {
|
|
fake := newFakeOpenRouter(t)
|
|
source, verifierInstance := newLLMSourceWithSpec(t, fake, llmSamplerFixtureSpec)
|
|
pushLLMSnapshot(t, verifierInstance)
|
|
|
|
_, err := source.NextAction(context.Background(), 1)
|
|
if err == nil || errors.Is(err, verifier.ErrNoAction) {
|
|
t.Fatalf("NextAction err = %v, want the run to stop on a sampler the model cannot draw", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "targets.generate()") {
|
|
t.Errorf("error does not name the offending leaf: %v", err)
|
|
}
|
|
if outcome := lastCall(t, source).Outcome; outcome != trace.LLMOutcomeCandidatesFailed {
|
|
t.Errorf("recorded outcome = %q, want %q", outcome, trace.LLMOutcomeCandidatesFailed)
|
|
}
|
|
}
|