Files
2026-07-13 13:00:08 +08:00

239 lines
7.2 KiB
Go

package memorycompiler
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestOutcomeForAbortedOnCancel(t *testing.T) {
if got := outcomeFor(nil, context.Canceled); got != "aborted" {
t.Fatalf("outcomeFor(canceled) = %q, want aborted", got)
}
wrapped := fmt.Errorf("run turn: %w", context.Canceled)
if got := outcomeFor(nil, wrapped); got != "aborted" {
t.Fatalf("outcomeFor(wrapped cancel) = %q, want aborted", got)
}
if got := outcomeFor(nil, fmt.Errorf("boom")); got != "failure" {
t.Fatalf("outcomeFor(real error) = %q, want failure", got)
}
}
func TestOutcomeForVerificationSignals(t *testing.T) {
pass := ToolRecord{Name: "bash", Args: `{"command":"go test ./internal/foo"}`}
fail := ToolRecord{Name: "bash", Args: `{"command":"go test ./internal/foo"}`, Error: "exit status 1"}
plainErr := ToolRecord{Name: "bash", Args: `{"command":"git push"}`, Error: "denied"}
plainOK := ToolRecord{Name: "read_file", Args: `{"path":"a.go"}`}
// A passing final verification confirms success even after earlier errors.
if got := outcomeFor([]ToolRecord{plainErr, pass}, nil); got != "success" {
t.Fatalf("passing verification after earlier error = %q, want success", got)
}
// A failing verification caps the turn at partial_success even when a
// later ordinary tool succeeded.
if got := outcomeFor([]ToolRecord{fail, plainOK}, nil); got != "partial_success" {
t.Fatalf("failing verification = %q, want partial_success", got)
}
// A passing verification followed by a later tool error falls back to the
// ordinary last-tool heuristic.
if got := outcomeFor([]ToolRecord{pass, plainErr}, nil); got != "partial_success" {
t.Fatalf("verification then error = %q, want partial_success", got)
}
// Non-verification commands keep the old behavior.
if got := outcomeFor([]ToolRecord{plainOK}, nil); got != "success" {
t.Fatalf("plain success = %q, want success", got)
}
}
func TestUpdateStrategyAbortedAndInjectedCounters(t *testing.T) {
st := updateStrategy(nil, "general", "aborted", false)
for _, s := range st {
if s.Samples() != 0 {
t.Fatalf("aborted outcome moved counters for %q: %+v", s.ID, s)
}
}
st = updateStrategy(st, "general", "success", true)
st = updateStrategy(st, "general", "failure", false)
for _, s := range st {
if s.ID != "general" {
continue
}
if s.Successes != 1 || s.Failures != 1 || s.InjectedSuccesses != 1 || s.InjectedFailures != 0 {
t.Fatalf("counters = %+v, want 1 success (injected), 1 failure (observed)", s)
}
return
}
t.Fatal("general strategy missing")
}
func TestAbortedTurnDoesNotMoveStrategyCounters(t *testing.T) {
dir := t.TempDir()
rt := New(dir)
_, turn := rt.StartTurn(context.Background(), "fix a bug", nil)
turn.Finish(context.Canceled)
st := readState(t, dir)
for _, s := range st.Strategies {
if s.Samples() != 0 {
t.Fatalf("aborted turn moved counters for %q: %+v", s.ID, s)
}
}
}
func TestIsCorrectiveFeedback(t *testing.T) {
positives := []string{
"不对,还是报错",
"没修好,重新看",
"still broken after your fix",
"这个改动没有生效",
"didn't fix the issue",
}
for _, in := range positives {
if !IsCorrectiveFeedback(in) {
t.Fatalf("IsCorrectiveFeedback(%q) = false, want true", in)
}
}
negatives := []string{
"",
"这个 API 不对外开放,请实现内部版本",
"两个面板不对称,请调整布局", // exclusion compound, not corrective
"修复登录页的 bug",
"thanks, looks good",
strings.Repeat("背景说明。", 40) + "上次不对的地方已经确认了", // corrective phrase beyond the head window
}
for _, in := range negatives {
if IsCorrectiveFeedback(in) {
t.Fatalf("IsCorrectiveFeedback(%q) = true, want false", in)
}
}
}
func TestReviseOutcomeFromFeedback(t *testing.T) {
dir := t.TempDir()
rt := New(dir)
_, turn := rt.StartTurn(context.Background(), "fix a bug", nil)
turn.Finish(nil) // no tools, no error → success
ref := turn.OutcomeRef()
if ref.Outcome != "success" || ref.Strategy != "bugfix-reproduce-first" {
t.Fatalf("unexpected ref: %+v", ref)
}
if !rt.ReviseOutcomeFromFeedback(ref, "不对,还是报错") {
t.Fatal("revision was not applied")
}
st := readState(t, dir)
for _, s := range st.Strategies {
if s.ID != ref.Strategy {
continue
}
if s.Successes != 0 || s.Failures != 1 {
t.Fatalf("counters not flipped: %+v", s)
}
}
found := false
for _, l := range st.Learnings {
if l.TraceID == ref.TraceID+":revision" && len(l.BadStrategies) > 0 {
found = true
}
}
if !found {
t.Fatal("revision learning missing")
}
b, err := os.ReadFile(filepath.Join(dir, tracesFile))
if err != nil {
t.Fatalf("read traces: %v", err)
}
revised := false
for _, ln := range strings.Split(strings.TrimSpace(string(b)), "\n") {
var tr ExecutionTrace
if json.Unmarshal([]byte(ln), &tr) != nil || tr.ID != ref.TraceID {
continue
}
revised = tr.Outcome == "failure" && strings.Contains(tr.FailureReason, "user feedback contradicted")
}
if !revised {
t.Fatalf("trace outcome not rewritten:\n%s", b)
}
// A ref whose trace is not in this runtime's log must be a no-op.
stale := ref
stale.TraceID = "trace-not-there"
if rt.ReviseOutcomeFromFeedback(stale, "还是不行") {
t.Fatal("stale ref should not revise anything")
}
}
func TestInjectedPersistedInTraceAndStrategy(t *testing.T) {
dir := t.TempDir()
rt := New(dir)
// Seed learned state so the next turn compiles a useful contract.
_, seed := rt.StartTurn(context.Background(), "fix a bug", nil)
seed.RecordToolResults([]ToolRecord{
{Name: "bash", Error: "exit status 1"},
{Name: "bash", Error: "exit status 1"},
})
seed.Finish(nil)
compiled, turn := rt.StartTurn(context.Background(), "fix the bug again", nil)
if compiled == "" {
t.Fatal("expected a compiled contract from learned state")
}
turn.Finish(nil) // injection not suppressed → Injected persists
b, err := os.ReadFile(filepath.Join(dir, tracesFile))
if err != nil {
t.Fatalf("read traces: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
var last ExecutionTrace
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &last); err != nil {
t.Fatalf("unmarshal last trace: %v", err)
}
if !last.Injected {
t.Fatalf("last trace not marked injected: %+v", last)
}
st := readState(t, dir)
for _, s := range st.Strategies {
if s.ID != "bugfix-reproduce-first" {
continue
}
if s.InjectedSuccesses != 1 {
t.Fatalf("injected successes = %d, want 1 (%+v)", s.InjectedSuccesses, s)
}
return
}
t.Fatal("bugfix strategy missing")
}
func TestSuppressedInjectionNotPersistedAsInjected(t *testing.T) {
dir := t.TempDir()
rt := New(dir)
_, seed := rt.StartTurn(context.Background(), "fix a bug", nil)
seed.RecordToolResults([]ToolRecord{
{Name: "bash", Error: "exit status 1"},
{Name: "bash", Error: "exit status 1"},
})
seed.Finish(nil)
compiled, turn := rt.StartTurn(context.Background(), "fix the bug again", nil)
if compiled == "" {
t.Fatal("expected a compiled contract from learned state")
}
turn.SuppressInjection() // observe mode
turn.Finish(nil)
st := readState(t, dir)
for _, s := range st.Strategies {
if s.InjectedSuccesses != 0 && s.ID == "bugfix-reproduce-first" {
t.Fatalf("observe-mode turn counted as injected: %+v", s)
}
}
}