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

169 lines
5.6 KiB
Go

package agent
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"reasonix/internal/agent/testutil"
"reasonix/internal/event"
"reasonix/internal/memorycompiler"
)
// readCompilerStrategies decodes the persisted Memory v5 strategy counters so
// the test can observe learning-side effects without exporting runtime state.
func readCompilerStrategies(t *testing.T, dir string) []memorycompiler.Strategy {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, "state.json"))
if err != nil {
t.Fatalf("read state: %v", err)
}
var st struct {
Strategies []memorycompiler.Strategy `json:"strategies"`
}
if err := json.Unmarshal(b, &st); err != nil {
t.Fatalf("unmarshal state: %v", err)
}
return st.Strategies
}
// requireBugfixCounters asserts the persisted bugfix strategy's success/failure
// counters.
func requireBugfixCounters(t *testing.T, dir, when string, successes, failures int) {
t.Helper()
for _, s := range readCompilerStrategies(t, dir) {
if s.ID != "bugfix-reproduce-first" {
continue
}
if s.Successes != successes || s.Failures != failures {
t.Fatalf("%s: counters = %d ok / %d fail, want %d ok / %d fail (%+v)",
when, s.Successes, s.Failures, successes, failures, s)
}
return
}
t.Fatalf("%s: bugfix strategy missing from state", when)
}
func TestCorrectiveFollowUpRevisesPreviousOutcome(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m", testutil.Turn{Text: "done"}, testutil.Turn{Text: "sorry"})
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID == "bugfix-reproduce-first" && (s.Successes != 1 || s.Failures != 0) {
t.Fatalf("turn 1 not recorded as success: %+v", s)
}
}
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
t.Fatalf("turn 2: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID != "bugfix-reproduce-first" {
continue
}
if s.Successes != 0 || s.Failures < 1 {
t.Fatalf("corrective follow-up did not revise turn 1 outcome: %+v", s)
}
return
}
t.Fatal("bugfix strategy missing from state")
}
func TestNeutralFollowUpDoesNotReviseOutcome(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m", testutil.Turn{Text: "done"}, testutil.Turn{Text: "done again"})
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
if err := a.Run(context.Background(), "fix another bug in the lexer"); err != nil {
t.Fatalf("turn 2: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID != "bugfix-reproduce-first" {
continue
}
if s.Successes != 2 || s.Failures != 0 {
t.Fatalf("neutral follow-up moved counters: %+v", s)
}
return
}
t.Fatal("bugfix strategy missing from state")
}
// TestStaleOutcomeRefNotRevisableAfterCompilerToggle covers the reviewer
// scenario: success turn records a ref → /memory-v5 off → intervening turn →
// on again → corrective input. The intervening turn must consume the ref even
// though the runtime was nil, so the old trace is not downgraded.
func TestStaleOutcomeRefNotRevisableAfterCompilerToggle(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m",
testutil.Turn{Text: "done"},
testutil.Turn{Text: "chat answer"},
testutil.Turn{Text: "sorry"},
)
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
requireBugfixCounters(t, dir, "after turn 1", 1, 0)
a.SetMemoryCompiler(nil) // /memory-v5 off
if err := a.Run(context.Background(), "tell me about the weather"); err != nil {
t.Fatalf("intervening turn: %v", err)
}
a.SetMemoryCompiler(memorycompiler.New(dir)) // /memory-v5 on
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
t.Fatalf("corrective turn: %v", err)
}
// The corrective input must not revise turn 1: its ref was consumed by the
// intervening turn. (The corrective turn itself may add new samples, so
// only assert turn 1's success survived.)
for _, s := range readCompilerStrategies(t, dir) {
if s.ID == "bugfix-reproduce-first" && s.Successes != 1 {
t.Fatalf("stale ref revised turn 1 after compiler toggle: %+v", s)
}
}
}
// TestOutcomeRefClearedOnSessionSwitch: switching sessions breaks the
// "immediately preceding turn" relationship, so the first input of the new
// session must not revise the old session's outcome even without intervening
// turns.
func TestOutcomeRefClearedOnSessionSwitch(t *testing.T) {
dir := t.TempDir()
rt := memorycompiler.New(dir)
mp := testutil.NewMock("m",
testutil.Turn{Text: "done"},
testutil.Turn{Text: "sorry"},
)
a := New(mp, echoRegistry(), NewSession(""), Options{MemoryCompiler: rt}, event.Discard)
if err := a.Run(context.Background(), "fix a bug in the parser"); err != nil {
t.Fatalf("turn 1: %v", err)
}
requireBugfixCounters(t, dir, "after turn 1", 1, 0)
a.SetSession(NewSession("")) // resume/switch to another conversation
if err := a.Run(context.Background(), "不对,还是报错"); err != nil {
t.Fatalf("new-session turn: %v", err)
}
for _, s := range readCompilerStrategies(t, dir) {
if s.ID == "bugfix-reproduce-first" && s.Successes != 1 {
t.Fatalf("new session's first input revised the old session's outcome: %+v", s)
}
}
}