返回 DeepSeek-Reasonix
chat_run_budget_test.go
根目录 / internal / control / chat_run_budget_test.go
1 package control
2
3 import (
4 "context"
5 "fmt"
6 "sync/atomic"
7 "testing"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 "reasonix/internal/tool"
13 )
14
15 // wanderingChatProvider never repeats itself: every round reads one more path,
16 // so the adaptive guards stay silent. It is the reported runaway's shape,
17 // driven through the controller rather than the agent. With max > 0 it stops
18 // on its own after that many rounds.
19 type wanderingChatProvider struct {
20 calls atomic.Int32
21 max int32
22 }
23
24 func (p *wanderingChatProvider) Name() string { return "wandering" }
25
26 func (p *wanderingChatProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
27 round := p.calls.Add(1)
28 ch := make(chan provider.Chunk, 4)
29 if p.max > 0 && round > p.max {
30 ch <- provider.Chunk{Type: provider.ChunkText, Text: "Done."}
31 ch <- provider.Chunk{Type: provider.ChunkDone}
32 close(ch)
33 return ch, nil
34 }
35 ch <- provider.Chunk{Type: provider.ChunkText, Text: "收到。先收集当前真实状态。"}
36 ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
37 ID: fmt.Sprintf("call-%d", round),
38 Name: "read_file",
39 Arguments: fmt.Sprintf(`{"path":"internal/pkg%d/file.go"}`, round),
40 }}
41 ch <- provider.Chunk{Type: provider.ChunkDone}
42 close(ch)
43 return ch, nil
44 }
45
46 func newChatBudgetController(t *testing.T, exec *agent.Agent) (*Controller, chan event.Event) {
47 t.Helper()
48 // Round admission does not depend on session durability. Keep this fixture
49 // in memory; persistence and writer authority have their own integration tests.
50 sink, done, _ := collectSink()
51 c := newOwnedTestController(t, Options{
52 Runner: exec,
53 Executor: exec,
54 Sink: sink,
55 })
56 t.Cleanup(c.autosaveWG.Wait)
57 return c, done
58 }
59
60 // An ordinary chat turn has no round ceiling. Reaching a high round count
61 // without crossing the cost or wall-clock budget means the rounds are
62 // individually cheap and fast, which is the case least worth stopping — 120
63 // rounds would have been truncated by the ceiling this replaced.
64 func TestOrdinaryChatTurnRunsPastTheOldRoundCeiling(t *testing.T) {
65 prov := &wanderingChatProvider{max: 120}
66 reg := tool.NewRegistry()
67 reg.Add(fakeControlTool{name: "read_file"})
68 exec := agent.New(prov, reg, agent.NewSession("sys"), agent.Options{}, event.Discard)
69 c, done := newChatBudgetController(t, exec)
70
71 c.Submit("collect the real state, then rewrite HANDOVER.md")
72 <-done
73
74 if got, want := prov.calls.Load(), int32(121); got != want {
75 t.Fatalf("provider rounds = %d, want %d (the model's own 120 plus its final answer)", got, want)
76 }
77 }
78
79 // The gate must not touch a turn the user bounded explicitly.
80 func TestExplicitMaxStepsOwnsTheOrdinaryTurn(t *testing.T) {
81 prov := &wanderingChatProvider{}
82 reg := tool.NewRegistry()
83 reg.Add(fakeControlTool{name: "read_file"})
84 exec := agent.New(prov, reg, agent.NewSession("sys"), agent.Options{MaxSteps: 3}, event.Discard)
85 c, done := newChatBudgetController(t, exec)
86
87 c.Submit("collect the real state")
88 <-done
89
90 if got := prov.calls.Load(); got != 4 {
91 t.Fatalf("provider rounds = %d, want the explicit 3 plus one summary", got)
92 }
93 }
94
94 lines GO