| 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 |