返回 DeepSeek-Reasonix
turn_phase_test.go
根目录 / internal / agent / turn_phase_test.go
1 package agent
2
3 import (
4 "context"
5 "slices"
6 "strings"
7 "testing"
8 "time"
9
10 "reasonix/internal/capability"
11 "reasonix/internal/event"
12 "reasonix/internal/evidence"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 type phaseSink struct {
18 phases []string
19 completions int
20 summaries []event.CompletionSummaryInfo
21 }
22
23 func (s *phaseSink) Emit(e event.Event) {
24 if e.Kind == event.TurnPhase {
25 s.phases = append(s.phases, string(e.PhaseName))
26 }
27 if e.Kind == event.CompletionSummary {
28 s.completions++
29 if e.Completion != nil {
30 s.summaries = append(s.summaries, *e.Completion)
31 }
32 }
33 }
34
35 func TestTurnEmitsWorkingPhase(t *testing.T) {
36 sink := &phaseSink{}
37 prov := &mockProvider{name: "p", chunks: []provider.Chunk{
38 {Type: provider.ChunkText, Text: "hi"},
39 {Type: provider.ChunkDone},
40 }}
41 a := New(prov, tool.NewRegistry(), NewSession("sys"), Options{}, sink)
42 if err := a.Run(context.Background(), "hello there"); err != nil {
43 t.Fatal(err)
44 }
45 if len(sink.phases) == 0 || sink.phases[0] != string(event.TurnPhaseWorking) {
46 t.Fatalf("phases = %v, want working first", sink.phases)
47 }
48 // Pure conversation should not emit a completion quality card.
49 if sink.completions != 0 {
50 t.Fatalf("completions = %d, want 0 for pure conversation", sink.completions)
51 }
52 }
53
54 func TestMutationProducesFactsWithoutQualitySummary(t *testing.T) {
55 sink := &phaseSink{}
56 a := New(nil, tool.NewRegistry(), NewSession("sys"), Options{}, sink)
57 a.task.ledger.Record(evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Mutation: true, Paths: []string{"a.go"}})
58 a.emitTurnShadows("add file")
59 if sink.completions != 0 || a.CompletionReceipt() == nil {
60 t.Fatal("expected factual result without host quality verdict")
61 }
62 }
63
64 func TestExecutionPolicyAbsentOnNewTurn(t *testing.T) {
65 prov := &mockProvider{name: "p", chunks: []provider.Chunk{
66 {Type: provider.ChunkText, Text: "ok"},
67 {Type: provider.ChunkDone},
68 }}
69 a := New(prov, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
70 _ = a.Run(context.Background(), "explain mutexes")
71 for _, m := range a.sess.conversation.Messages {
72 if m.Role == provider.RoleUser && strings.Contains(m.Content, "<execution-policy") {
73 t.Fatal("new turns must not inject execution-policy")
74 }
75 }
76 }
77
78 // The phase pair around a tool batch is what gives ProviderWaitMs and
79 // ToolExecMs their meaning, so the order is asserted, not just the first phase.
80 func TestToolRoundAlternatesProviderAndToolPhases(t *testing.T) {
81 sink := &phaseSink{}
82 reg := tool.NewRegistry()
83 reg.Add(fakeTool{name: "read_file", readOnly: true})
84 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
85 {toolCallChunk("r1", "read_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}},
86 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
87 }}
88 a := New(prov, reg, NewSession("sys"), Options{}, sink)
89 if err := a.Run(context.Background(), "read a.go"); err != nil {
90 t.Fatal(err)
91 }
92 want := []string{
93 string(event.TurnPhaseWorking),
94 string(event.TurnPhaseChecking),
95 string(event.TurnPhaseWorking),
96 }
97 if !slices.Equal(sink.phases, want) {
98 t.Fatalf("phases = %v, want %v", sink.phases, want)
99 }
100 assertToolPhasesClosed(t, sink.phases)
101 }
102
103 // assertToolPhasesClosed guards the accounting invariant: a tool-billed phase
104 // left open swallows whatever runs next, and what runs next is usually a
105 // provider round, so its wait would be billed as tool time.
106 func assertToolPhasesClosed(t *testing.T, phases []string) {
107 t.Helper()
108 for i, phase := range phases {
109 switch phase {
110 case string(event.TurnPhaseChecking), string(event.TurnPhaseVerifying):
111 if i == len(phases)-1 {
112 t.Fatalf("phase %q at %d is never closed: %v", phase, i, phases)
113 }
114 }
115 }
116 }
117
118 // RecordPhaseMs drops anything under a millisecond, so the tool sleeps: the
119 // assertion is that the bucket is reachable at all, which it was not before.
120 func TestToolRoundBillsPhaseDurations(t *testing.T) {
121 audit := &capability.Audit{}
122 reg := tool.NewRegistry()
123 reg.Add(fakeTool{name: "read_file", readOnly: true, delay: 5 * time.Millisecond})
124 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
125 {toolCallChunk("r1", "read_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}},
126 {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
127 }}
128 a := New(prov, reg, NewSession("sys"), Options{CapabilityAudit: audit}, event.Discard)
129 if err := a.Run(context.Background(), "read a.go"); err != nil {
130 t.Fatal(err)
131 }
132 phases := audit.Snapshot().Phases
133 if phases.ToolExecMs <= 0 {
134 t.Fatalf("ToolExecMs = %d, want the tool span billed", phases.ToolExecMs)
135 }
136 if phases.ProviderWaitMs < 0 {
137 t.Fatalf("ProviderWaitMs = %d, want non-negative", phases.ProviderWaitMs)
138 }
139 }
140
140 lines GO