| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "reflect" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/provider" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | func TestRunSubAgentRetriesReasoningOnlyStopForVisibleFinal(t *testing.T) { |
| 15 | prov := &scriptedProvider{name: "sub", turns: [][]provider.Chunk{ |
| 16 | { |
| 17 | {Type: provider.ChunkReasoning, Text: "The analysis is complete."}, |
| 18 | {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop", TotalTokens: 10}}, |
| 19 | {Type: provider.ChunkDone}, |
| 20 | }, |
| 21 | { |
| 22 | {Type: provider.ChunkText, Text: "visible result"}, |
| 23 | {Type: provider.ChunkDone}, |
| 24 | }, |
| 25 | }} |
| 26 | sess := NewSession("sys") |
| 27 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous task"}) |
| 28 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "previous result"}) |
| 29 | |
| 30 | answer, err := RunSubAgentWithSession( |
| 31 | testTaskContext(), deepseekThinkingProvider{prov}, tool.NewRegistry(), sess, |
| 32 | "analyze the code", Options{SubagentDepth: 1}, event.Discard, |
| 33 | ) |
| 34 | if err != nil { |
| 35 | t.Fatalf("RunSubAgentWithSession: %v", err) |
| 36 | } |
| 37 | if answer != "visible result" { |
| 38 | t.Fatalf("answer = %q, want visible result", answer) |
| 39 | } |
| 40 | if prov.call != 2 { |
| 41 | t.Fatalf("provider calls = %d, want 2", prov.call) |
| 42 | } |
| 43 | if got := lastUser(prov.requests[1]); !strings.Contains(got, "visible answer") { |
| 44 | t.Fatalf("retry prompt = %q, want visible-answer nudge", got) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestRunSubAgentDoesNotReturnStalePreToolTextAfterReasoningOnlyStop(t *testing.T) { |
| 49 | prov := &scriptedProvider{name: "sub", turns: [][]provider.Chunk{ |
| 50 | { |
| 51 | {Type: provider.ChunkReasoning, Text: "I need to inspect the input."}, |
| 52 | {Type: provider.ChunkText, Text: "I'll inspect first."}, |
| 53 | toolCallChunk("call-1", "echo", `{"text":"input"}`), |
| 54 | {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "tool_calls", TotalTokens: 10}}, |
| 55 | {Type: provider.ChunkDone}, |
| 56 | }, |
| 57 | { |
| 58 | {Type: provider.ChunkReasoning, Text: "The inspection is complete."}, |
| 59 | {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop", TotalTokens: 10}}, |
| 60 | {Type: provider.ChunkDone}, |
| 61 | }, |
| 62 | { |
| 63 | {Type: provider.ChunkText, Text: "final findings"}, |
| 64 | {Type: provider.ChunkDone}, |
| 65 | }, |
| 66 | }} |
| 67 | reg := tool.NewRegistry() |
| 68 | reg.Add(echoTool{}) |
| 69 | |
| 70 | answer, err := RunSubAgentWithSession( |
| 71 | testTaskContext(), deepseekThinkingProvider{prov}, reg, NewSession("sys"), |
| 72 | "inspect the input", Options{SubagentDepth: 1}, event.Discard, |
| 73 | ) |
| 74 | if err != nil { |
| 75 | t.Fatalf("RunSubAgentWithSession: %v", err) |
| 76 | } |
| 77 | if answer != "final findings" { |
| 78 | t.Fatalf("answer = %q, want final findings (not stale tool preamble)", answer) |
| 79 | } |
| 80 | if prov.call != 3 { |
| 81 | t.Fatalf("provider calls = %d, want 3", prov.call) |
| 82 | } |
| 83 | if got := lastUser(prov.requests[2]); !strings.Contains(got, "visible answer") { |
| 84 | t.Fatalf("retry prompt = %q, want visible-answer nudge", got) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestRunSubAgentStopsAfterRepeatedReasoningOnlyStops(t *testing.T) { |
| 89 | prov := &scriptedProvider{name: "sub", turns: [][]provider.Chunk{ |
| 90 | {{Type: provider.ChunkReasoning, Text: "thinking 1"}, {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop"}}, {Type: provider.ChunkDone}}, |
| 91 | {{Type: provider.ChunkReasoning, Text: "thinking 2"}, {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop"}}, {Type: provider.ChunkDone}}, |
| 92 | {{Type: provider.ChunkReasoning, Text: "thinking 3"}, {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop"}}, {Type: provider.ChunkDone}}, |
| 93 | }} |
| 94 | |
| 95 | _, err := RunSubAgentWithSession( |
| 96 | testTaskContext(), deepseekThinkingProvider{prov}, tool.NewRegistry(), NewSession("sys"), |
| 97 | "analyze the code", Options{SubagentDepth: 1}, event.Discard, |
| 98 | ) |
| 99 | if err == nil || !strings.Contains(err.Error(), "visible final answer") { |
| 100 | t.Fatalf("error = %v, want bounded visible-final failure", err) |
| 101 | } |
| 102 | if prov.call != maxEmptyFinalBlocks { |
| 103 | t.Fatalf("provider calls = %d, want %d", prov.call, maxEmptyFinalBlocks) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestCoordinatorToolPlannerRetriesReasoningOnlyStopForVisiblePlan(t *testing.T) { |
| 108 | plannerScript := &scriptedProvider{name: "planner", turns: [][]provider.Chunk{ |
| 109 | { |
| 110 | {Type: provider.ChunkReasoning, Text: "I need to inspect the input."}, |
| 111 | {Type: provider.ChunkText, Text: "I'll inspect first."}, |
| 112 | toolCallChunk("call-1", "echo", `{"text":"input"}`), |
| 113 | {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "tool_calls", TotalTokens: 10}}, |
| 114 | {Type: provider.ChunkDone}, |
| 115 | }, |
| 116 | { |
| 117 | {Type: provider.ChunkReasoning, Text: "The plan is ready."}, |
| 118 | {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop", TotalTokens: 10}}, |
| 119 | {Type: provider.ChunkDone}, |
| 120 | }, |
| 121 | { |
| 122 | {Type: provider.ChunkReasoning, Text: "Submitting the plan now."}, |
| 123 | toolCallChunk("call-2", "submit_plan", `{"objective":"apply the fix","steps":[{"title":"apply the verified fix"}]}`), |
| 124 | {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "tool_calls", TotalTokens: 10}}, |
| 125 | {Type: provider.ChunkDone}, |
| 126 | }, |
| 127 | }} |
| 128 | exec := &mockProvider{name: "executor", chunks: []provider.Chunk{ |
| 129 | {Type: provider.ChunkText, Text: "Done."}, |
| 130 | {Type: provider.ChunkDone}, |
| 131 | }} |
| 132 | plannerTools := tool.NewRegistry() |
| 133 | plannerTools.Add(echoTool{}) |
| 134 | executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard) |
| 135 | coord := NewCoordinator( |
| 136 | deepseekThinkingProvider{plannerScript}, NewSession("planner-sys"), nil, |
| 137 | PlannerToolRegistry(plannerTools), Options{}, executor, 0, event.Discard, nil, |
| 138 | ) |
| 139 | |
| 140 | if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil { |
| 141 | t.Fatalf("Run: %v", err) |
| 142 | } |
| 143 | if plannerScript.call != 3 { |
| 144 | t.Fatalf("planner calls = %d, want 3", plannerScript.call) |
| 145 | } |
| 146 | if len(exec.requests) == 0 { |
| 147 | t.Fatal("executor received no handoff request") |
| 148 | } |
| 149 | got := lastUser(exec.requests[0]) |
| 150 | if !strings.Contains(got, "apply the verified fix") || !strings.Contains(got, executorHandoffMarker) { |
| 151 | t.Fatalf("executor input = %q, want visible plan and handoff marker", got) |
| 152 | } |
| 153 | if strings.Contains(got, "I'll inspect first.") { |
| 154 | t.Fatalf("executor input contains stale planner preamble: %q", got) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestCoordinatorRollbackAfterRewriteDropsReasoningOnlyRetryTail(t *testing.T) { |
| 159 | plannerSess := NewSession("planner-sys") |
| 160 | before := plannerSess.Snapshot() |
| 161 | rewriteBefore := plannerSess.RewriteVersion() |
| 162 | |
| 163 | compactedWithEvidence := []provider.Message{ |
| 164 | {Role: provider.RoleSystem, Content: "planner-sys"}, |
| 165 | {Role: provider.RoleUser, Content: summaryTagOpen + "\ncompacted research\n" + summaryTagClose}, |
| 166 | {Role: provider.RoleAssistant, Content: "Visible evidence collected before the current tool round."}, |
| 167 | {Role: provider.RoleUser, Content: "Plan the current task."}, |
| 168 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "read-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}}, |
| 169 | {Role: provider.RoleTool, ToolCallID: "read-1", Name: "read_file", Content: "package main"}, |
| 170 | } |
| 171 | plannerSess.Replace(compactedWithEvidence) |
| 172 | plannerSess.IncrementRewrite() |
| 173 | plannerSess.Add(provider.Message{Role: provider.RoleAssistant, ReasoningContent: "first hidden-only plan"}) |
| 174 | plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: "provide a visible plan"}) |
| 175 | plannerSess.Add(provider.Message{Role: provider.RoleAssistant, ReasoningContent: "second hidden-only plan"}) |
| 176 | plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: "provide a visible plan"}) |
| 177 | plannerSess.Add(provider.Message{Role: provider.RoleAssistant, ReasoningContent: "third hidden-only plan"}) |
| 178 | |
| 179 | coord := &Coordinator{plannerSess: plannerSess} |
| 180 | coord.rollbackPlannerTurn(before, rewriteBefore) |
| 181 | |
| 182 | got := plannerSess.Snapshot() |
| 183 | if !reflect.DeepEqual(got, compactedWithEvidence) { |
| 184 | t.Fatalf("rewrite-aware rollback changed compacted or completed tool evidence:\n got=%+v\nwant=%+v", got, compactedWithEvidence) |
| 185 | } |
| 186 | if normalized := provider.NormalizeMessages(got); !reflect.DeepEqual(normalized, got) { |
| 187 | t.Fatalf("preserved planner history is not provider-coherent:\n got=%+v\nnormalized=%+v", got, normalized) |
| 188 | } |
| 189 | } |
| 190 |