返回 DeepSeek-Reasonix
context_usage_test.go
根目录 / internal / agent / context_usage_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "testing"
9
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 "reasonix/internal/tool"
13 )
14
15 // bigSchemaTool mimics a real built-in tool whose JSON schema is large enough
16 // to matter: the compaction trigger counts tool schemas in the prompt it sizes,
17 // so the gauge must too.
18 type bigSchemaTool struct{}
19
20 func (bigSchemaTool) Name() string { return "big_schema" }
21 func (bigSchemaTool) Description() string { return "a tool with a large schema" }
22 func (bigSchemaTool) Schema() json.RawMessage {
23 return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"` +
24 strings.Repeat("a fairly long property description that consumes tokens. ", 200) +
25 `"},"query":{"type":"string","description":"another long field to inflate the schema"}}}`)
26 }
27 func (bigSchemaTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
28 func (bigSchemaTool) ReadOnly() bool { return true }
29
30 func usageFixture(t *testing.T, toolResults int) *Agent {
31 t.Helper()
32 big := strings.Repeat("line\n", 400)
33 msgs := []provider.Message{
34 {Role: provider.RoleSystem, Content: "system"},
35 {Role: provider.RoleUser, Content: "task"},
36 }
37 for i := range toolResults {
38 id := fmt.Sprintf("call-%d", i)
39 msgs = append(msgs,
40 provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}},
41 provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big},
42 )
43 }
44 return New(nil, tool.NewRegistry(), &Session{Messages: msgs}, Options{
45 ContextWindow: 1_000_000,
46 RecentKeep: 2,
47 ArchiveDir: t.TempDir(),
48 }, event.Discard)
49 }
50
51 // The gauge and the compaction trigger must read the same number. Feeding the
52 // gauge from the last turn's provider usage let a session report 8% while it
53 // was compacting: that number lags a turn, counts completion tokens the trigger
54 // never looks at, and is zero until the first turn of a rebound session.
55 func TestContextUsedTokensMatchesTheTriggerInput(t *testing.T) {
56 a := usageFixture(t, 12)
57 // A stale, tiny reading from the previous turn — exactly what a fold leaves
58 // behind, and what the gauge used to display.
59 a.sess.output.lastUsage.Store(&provider.Usage{PromptTokens: 900, CompletionTokens: 100})
60
61 used := a.ContextUsedTokens()
62 if got := a.ContextMaintenanceSnapshot().ProjectedTokens; used != got {
63 t.Fatalf("gauge = %d, trigger input = %d; they must be the same measurement", used, got)
64 }
65 if used <= 1_000 {
66 t.Fatalf("gauge = %d, want the real view size rather than the last turn's %d", used, 1_000)
67 }
68 }
69
70 // Regression: the gauge used the message-only estimator while the trigger also
71 // sizes tool schemas, so with a non-empty tool registry the gauge under-reported
72 // the fill — a session could display 80% while the trigger had already crossed
73 // compact_ratio (and after a compaction the two disagreed again). The gauge must
74 // call the exact same estimator as the trigger, tool schemas included.
75 func TestContextUsedTokensIncludesToolSchemasLikeTheTrigger(t *testing.T) {
76 reg := tool.NewRegistry()
77 reg.Add(bigSchemaTool{})
78 msgs := []provider.Message{
79 {Role: provider.RoleSystem, Content: "system"},
80 {Role: provider.RoleUser, Content: "task"},
81 }
82 a := New(nil, reg, &Session{Messages: msgs}, Options{
83 ContextWindow: 1_000_000,
84 RecentKeep: 2,
85 ArchiveDir: t.TempDir(),
86 }, event.Discard)
87
88 used := a.ContextUsedTokens()
89 // The trigger's own measurement, verbatim.
90 if got := a.ContextMaintenanceSnapshot().ProjectedTokens; used != got {
91 t.Fatalf("gauge = %d, trigger input = %d; they must be the same measurement", used, got)
92 }
93 // The gauge must include the tool schemas the trigger counts. The old
94 // message-only estimator ignored them and reported exactly the message cost.
95 if msgOnly := a.estimatedPromptTokens(a.modelVisibleMessages()); used <= msgOnly {
96 t.Fatalf("gauge = %d, message-only estimate = %d; the gauge must price tool schemas like the trigger", used, msgOnly)
97 }
98 }
99
100 func TestContextUsedTokensFollowsLiveToolRegistry(t *testing.T) {
101 reg := tool.NewRegistry()
102 a := New(nil, reg, &Session{Messages: []provider.Message{
103 {Role: provider.RoleSystem, Content: "system"},
104 {Role: provider.RoleUser, Content: "task"},
105 }}, Options{ContextWindow: 1_000_000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
106
107 withoutTools := a.ContextUsedTokens()
108 reg.Add(bigSchemaTool{})
109 withTools := a.ContextUsedTokens()
110 if got := a.ContextMaintenanceSnapshot().ProjectedTokens; withTools != got {
111 t.Fatalf("gauge after tool registration = %d, trigger input = %d", withTools, got)
112 }
113 if withTools <= withoutTools {
114 t.Fatalf("gauge did not grow after tool registration: %d -> %d", withoutTools, withTools)
115 }
116
117 if removed := reg.RemovePrefix("big_"); removed != 1 {
118 t.Fatalf("removed %d tools, want 1", removed)
119 }
120 if got := a.ContextUsedTokens(); got != withoutTools {
121 t.Fatalf("gauge after tool removal = %d, want %d", got, withoutTools)
122 }
123
124 reg.Add(bigSchemaTool{})
125 if got := a.ContextUsedTokens(); got != withTools {
126 t.Fatalf("gauge after re-registration = %d, want %d", got, withTools)
127 }
128 if removed := reg.SuspendPrefix("big_"); removed != 1 {
129 t.Fatalf("suspended %d tools, want 1", removed)
130 }
131 if got := a.ContextUsedTokens(); got != withoutTools {
132 t.Fatalf("gauge after tool suspension = %d, want %d", got, withoutTools)
133 }
134 }
135
136 func TestContextUsedTokensIsZeroWithoutASession(t *testing.T) {
137 a := &Agent{}
138 if got := a.ContextUsedTokens(); got != 0 {
139 t.Fatalf("gauge without a session = %d, want 0 so the frontend hides it", got)
140 }
141 }
142
143 func TestContextUsedTokensFollowsTheTranscript(t *testing.T) {
144 a := usageFixture(t, 4)
145 before := a.ContextUsedTokens()
146 if before != a.ContextUsedTokens() {
147 t.Fatal("repeated reads of an unchanged view disagreed")
148 }
149
150 a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("more context\n", 500)})
151 after := a.ContextUsedTokens()
152 if after <= before {
153 t.Fatalf("gauge %d -> %d, want the appended turn counted", before, after)
154 }
155 }
156
156 lines GO