Files
DeepSeek-Reasonix/internal/runtime/agent/compact_ablation_test.go
T
yhh 9ca5da9aa5 refactor(agent): the context window owns its state and says what it needs
Projection, compaction and token estimation were 121 methods on *Agent,
free to touch any of its state. They now form one subsystem inside the
agent package:

- windowState holds what the window owns for a conversation: the
  installed projection, the compaction in flight, the pressure latches.
  It used to sit as eight loose fields on sessionRuntime.
  sessionRuntime.reset hands a new conversation to windowState.reset, and
  the lifetime guard in sessionstate_test.go now holds both structs to
  their reset and carry-over lists.
- The window's methods live on *contextWindow, which carries only its
  state, the session, the configuration, the services and a windowHost.
- windowHost is everything the window asks of the loop: eight callbacks
  (the todo and host-context tails, tool facts, the provider projection,
  preparing a sampling request, the extension compaction intercepts,
  failure annotation) and Session. It was derived from the compiler, not
  written by hand, so it is the whole dependency.
- The loop reaches the window through a.window(). The agent's public
  context API (ContextBudget, CacheState, LoadProjectionSidecar and the
  rest) is unchanged and forwards to it.

The new repolint rule window-state holds the boundary. Only methods on
the window's own types may read sess.win, and the owners are declared in
tools/repolint/windowstate.go. REASONIX.md names the boundary.

Behaviour is unchanged. No test was added or removed. Tests reach the
window's internals through a.window() like the loop does.
2026-09-23 23:38:19 -07:00

127 lines
5.3 KiB
Go

package agent
import (
"context"
"fmt"
"reasonix/internal/state/sessionstore"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
)
func armedAgent(t *testing.T, sess *sessionstore.Session, arm ablation.Set) *Agent {
t.Helper()
return New(&fakeProvider{reply: "Work continued."}, tool.NewRegistry(), sess, Options{
ContextWindow: 5000, CompactRatio: 0.5, RecentKeep: 2,
ArchiveDir: testenv.TempDir(t), Ablation: arm,
}, event.Discard)
}
// indexHeavySession makes far more index lines than any arm's budget can hold,
// so the four scales are separated by what they trim rather than by what the
// fixture happened to produce.
func indexHeavySession(calls int) *sessionstore.Session {
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "sweep the tree"},
}
for i := range calls {
id := fmt.Sprintf("c%d", i)
msgs = append(msgs,
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":"internal/pkg%03d/service.go"}`, i)},
}},
provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: strings.Repeat("line ", 200)},
)
}
return &sessionstore.Session{Messages: msgs}
}
// The axis has to reach the index, not just the arm name: a scale that changes
// nothing measures nothing.
func TestFoldIndexScaleChangesWhatTheModelSees(t *testing.T) {
sizes := map[ablation.FoldIndexScale]int{}
for _, scale := range []ablation.FoldIndexScale{
ablation.FoldIndexDefault, ablation.FoldIndexHalf, ablation.FoldIndexQuarter, ablation.FoldIndexOff,
} {
a := New(&fakeProvider{reply: "Swept."}, tool.NewRegistry(), indexHeavySession(400), Options{
ContextWindow: 120000, CompactRatio: 0.5, RecentKeep: 2,
ArchiveDir: testenv.TempDir(t), Ablation: ablation.Set{}.WithFoldIndex(scale),
}, event.Discard)
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
t.Fatalf("scale %q prepare = %v", scale, err)
}
canonical, _ := a.sess.conversation.SnapshotMessagesVersion()
lines := 0
for _, m := range modelVisibleFromProjection(a.sess.win.compactionState.Projection, canonical) {
if _, index := splitFoldIndex(m.Content); index != "" {
lines += len(indexBodyLines(index))
}
}
sizes[scale] = lines
t.Logf("scale=%-7q budget=%4d index lines=%d", scale, a.window().foldIndexBudget(), lines)
}
if sizes[ablation.FoldIndexOff] != 0 {
t.Errorf("the off arm still published %d index lines", sizes[ablation.FoldIndexOff])
}
if sizes[ablation.FoldIndexDefault] <= sizes[ablation.FoldIndexQuarter] {
t.Errorf("quarter (%d lines) did not shrink the default (%d lines); the axis is inert",
sizes[ablation.FoldIndexQuarter], sizes[ablation.FoldIndexDefault])
}
// Monotone, and each step separated: an arm that lands on the same index as
// its neighbour measures the same thing twice.
if sizes[ablation.FoldIndexHalf] >= sizes[ablation.FoldIndexDefault] {
t.Errorf("half (%d) did not shrink the default (%d)", sizes[ablation.FoldIndexHalf], sizes[ablation.FoldIndexDefault])
}
if sizes[ablation.FoldIndexQuarter] >= sizes[ablation.FoldIndexHalf] {
t.Errorf("quarter (%d) did not shrink half (%d)", sizes[ablation.FoldIndexQuarter], sizes[ablation.FoldIndexHalf])
}
}
// An arm without search must not be told about it anywhere: not by the tool it
// holds, and not by the line the index writes when it drops an address.
func TestRecallSearchArmIsToldNothingItCannotUse(t *testing.T) {
sess, _ := horizonSession(retrievalCarriers()[0], 6)
a := armedAgent(t, sess, ablation.New(ablation.RecallSearch))
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
t.Fatalf("prepare = %v", err)
}
if hint := a.window().droppedIndexHint(); strings.Contains(hint, "query") {
t.Errorf("the dropped-entries line offers a query to an arm without search: %q", hint)
}
if _, err := a.RecallContext(context.Background(), tool.RecallRequest{Query: "timeout ownership"}); err == nil {
t.Fatal("search ran in an arm that ablated it")
}
// The read half is untouched: an address the index still carries stays
// readable, which is what the pre-search tool could always do.
if _, err := a.RecallContext(context.Background(), tool.RecallRequest{Positions: []int{2}}); err != nil {
t.Errorf("read = %v, want the read half to keep working", err)
}
}
// The control arm keeps both halves.
func TestControlArmKeepsSearch(t *testing.T) {
sess, at := horizonSession(retrievalCarriers()[0], 6)
a := armedAgent(t, sess, ablation.Set{})
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
t.Fatalf("prepare = %v", err)
}
if hint := a.window().droppedIndexHint(); !strings.Contains(hint, "query") {
t.Errorf("the control arm's dropped-entries line does not mention search: %q", hint)
}
res, err := a.RecallContext(context.Background(), tool.RecallRequest{
Query: "timeout ownership controller middleware cancellation",
})
if err != nil {
t.Fatalf("search = %v", err)
}
if hitRank(res, at) == 0 {
t.Errorf("control arm search missed #%d: %v", at, hitPositions(res))
}
}