Files
DeepSeek-Reasonix/internal/runtime/agent/sessionstate_test.go
T
esengine be5c98c744 refactor(agent): remove missing-tool-call-reasoning detection and recovery
The loop no longer classifies a tool-call turn that arrived without
reasoning, retries it, or keeps a file-backed cooldown for it. Removes the
watch and the cooldown state, the deferred stream sink they needed, the
provider policy interfaces and their implementations, the descriptor field on
the extension wire (schema and SDK regenerated), the state-dir path, the
run-metrics counters and the protocol-recovery kinds.

The protocol-recovery audit channel itself stays for now.
2026-11-02 03:26:03 -08:00

200 lines
6.4 KiB
Go

package agent
import (
"go/ast"
"go/parser"
"go/token"
"reasonix/internal/state/sessionstore"
"testing"
)
// sessionReset names the fields a new conversation starts from. taskRuntime
// gets this for free — one assignment zeroes anything unlisted — but a struct
// holding atomics and mutexes cannot be assigned, so reset must name each field
// and this list is what keeps it honest.
var sessionReset = map[string]bool{
"mu": true,
"conversation": true,
"output": true,
"cacheHit": true,
"cacheMiss": true,
"mode": true,
"win": true, // windowState.reset, held to its own lists below
}
// sessionCarryOver names the fields reset deliberately leaves alone, each with
// an owner that rebinds it. Being on this list is a claim that someone else
// sets the field for the new conversation — not that it does not matter.
var sessionCarryOver = map[string]bool{
"path": true, // preflight rebinds on the next transcript bind
"todoMu": true,
"todoState": true, // SetSession rebuilds it from the new snapshot
// lastPrefixShape survives the swap today; the next request compares its
// prefix against the replaced conversation's shape. Left as found here.
"lastPrefixShape": true,
"haveLastPrefixShape": true,
// The surface a later request estimates against. Carried for the same
// reason as the shape above: the next request rebinds it before reading.
"lastProviderSchemas": true,
// A withdrawn slot outlives the conversation that saw it, so the queued
// fact is still true and still undelivered; the next round drains it.
"hostNotices": true,
// The depth the user switched to is the session's setting, saved to their
// config alongside, so a new conversation keeps it.
"effort": true,
// Drained by the receipt of the turn it happened in, which ends before any swap.
"claimYields": true,
}
// windowReset and windowCarryOver are the same two lists for windowState.
var windowReset = map[string]bool{
"summaryPrefix": true,
"compactionMu": true,
"compactionState": true,
"cacheState": true,
"compaction": true,
"coveredHash": true,
}
var windowCarryOver = map[string]bool{
"compactionRunMu": true, // a singleflight latch, not conversation state
"checkpointState": true, // preflight rebinds with the transcript
}
// lifetimeSubject is one struct whose reset the lists describe.
type lifetimeSubject struct {
file, typeName, recv string
reset, carry map[string]bool
}
var lifetimeSubjects = []lifetimeSubject{
{"sessionstate.go", "sessionRuntime", "r", sessionReset, sessionCarryOver},
{"context_window.go", "windowState", "w", windowReset, windowCarryOver},
}
func structFields(t *testing.T, path, typeName string) map[string]bool {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
fields := map[string]bool{}
ast.Inspect(file, func(n ast.Node) bool {
spec, ok := n.(*ast.TypeSpec)
if !ok || spec.Name.Name != typeName {
return true
}
st, ok := spec.Type.(*ast.StructType)
if !ok {
return false
}
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
// An embedded type contributes its own name.
if ident, ok := field.Type.(*ast.Ident); ok {
fields[ident.Name] = true
}
continue
}
for _, name := range field.Names {
fields[name.Name] = true
}
}
return false
})
if len(fields) == 0 {
t.Fatalf("%s has no fields; the guard would pass vacuously", typeName)
}
return fields
}
func TestSessionRuntimeLifetimeListsCoverTheStruct(t *testing.T) {
for _, sub := range lifetimeSubjects {
fields := structFields(t, sub.file, sub.typeName)
for _, list := range []map[string]bool{sub.reset, sub.carry} {
for name := range list {
if !fields[name] {
t.Errorf("the lifetime lists name %q, which %s no longer has", name, sub.typeName)
}
}
}
for name := range fields {
switch {
case sub.reset[name] && sub.carry[name]:
t.Errorf("%s.%s is listed as both reset and carried", sub.typeName, name)
case !sub.reset[name] && !sub.carry[name]:
t.Errorf("%s.%s is on neither list; decide whether a new conversation starts from it", sub.typeName, name)
}
}
}
}
// The list above is a claim about reset's body, so it is read back from the
// source: a field dropped from reset stops being covered, and the mismatch
// fails here instead of surfacing as state leaking between conversations.
func TestSessionRuntimeResetAssignsEveryResetField(t *testing.T) {
for _, sub := range lifetimeSubjects {
touched := resetTouched(t, sub.file, sub.recv)
for name := range sub.reset {
if !touched[name] {
t.Errorf("reset never touches %s.%s, but the list says a new conversation starts from it", sub.typeName, name)
}
}
}
}
func resetTouched(t *testing.T, path, recv string) map[string]bool {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
touched := map[string]bool{}
ast.Inspect(file, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name.Name != "reset" {
return true
}
ast.Inspect(fn, func(inner ast.Node) bool {
sel, ok := inner.(*ast.SelectorExpr)
if !ok {
return true
}
if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == recv {
touched[sel.Sel.Name] = true
}
return true
})
return false
})
return touched
}
func TestSetSessionRestartsTheConversationState(t *testing.T) {
a := &Agent{}
a.sess.cacheHit.Store(11)
a.sess.cacheMiss.Store(7)
a.sess.win.compaction.stuck = true
a.sess.win.compaction.lastReported = maintenanceReport{reason: NoopNoNewClosedPrefix, turn: 9}
a.sess.win.compactionState = sessionstore.CompactionState{}
next := sessionstore.NewSession("")
a.SetSession(next)
if a.sess.session() != next {
t.Error("SetSession did not bind the new conversation")
}
if a.sess.cacheHit.Load() != 0 || a.sess.cacheMiss.Load() != 0 {
t.Errorf("cache tallies = %d/%d, want a fresh aggregate", a.sess.cacheHit.Load(), a.sess.cacheMiss.Load())
}
if a.sess.win.compaction.stuck || a.sess.win.compaction.lastReported.turn != 0 {
t.Errorf("compaction progress = stuck:%t lastReported:%+v, want it restarted",
a.sess.win.compaction.stuck, a.sess.win.compaction.lastReported)
}
if a.sess.win.cacheState != CacheStateUnknown {
t.Errorf("cacheState = %q, want %q", a.sess.win.cacheState, CacheStateUnknown)
}
}