| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/agent" |
| 8 | "reasonix/internal/event" |
| 9 | "reasonix/internal/provider" |
| 10 | "reasonix/internal/session" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | func manualReadinessController(t *testing.T, turns [][]provider.Chunk) (*Controller, *scriptedTurns) { |
| 15 | t.Helper() |
| 16 | reg := tool.NewRegistry() |
| 17 | reg.Add(minimalFakeTool{name: "write_file"}) |
| 18 | reg.Add(minimalFakeTool{name: "read_file", readOnly: true}) |
| 19 | if todoWrite, ok := tool.LookupBuiltin("todo_write"); ok { |
| 20 | reg.Add(todoWrite) |
| 21 | } |
| 22 | prov := &scriptedTurns{turns: turns} |
| 23 | executor := agent.New(prov, reg, agent.NewSession("stable-system-prefix"), agent.Options{}, event.Discard) |
| 24 | c := newOwnedTestController(t, Options{Runner: executor, Executor: executor, Sink: event.Discard}) |
| 25 | t.Cleanup(c.Close) |
| 26 | return c, prov |
| 27 | } |
| 28 | |
| 29 | func syntheticUserTurnCount(messages []provider.Message) int { |
| 30 | count := 0 |
| 31 | for _, message := range messages { |
| 32 | if message.Role == provider.RoleUser && IsSyntheticUserMessage(message.Content) { |
| 33 | count++ |
| 34 | } |
| 35 | } |
| 36 | return count |
| 37 | } |
| 38 | |
| 39 | func TestStandardStopsAfterOneVisibleTurn(t *testing.T) { |
| 40 | c, prov := manualReadinessController(t, [][]provider.Chunk{ |
| 41 | textTurn("已完成学习说明。下一步可以由你决定。"), |
| 42 | textTurn("不应被隐藏续跑消费"), |
| 43 | }) |
| 44 | if err := c.SetQualityFloor(QualityFloorStandard); err != nil { |
| 45 | t.Fatalf("SetQualityFloor: %v", err) |
| 46 | } |
| 47 | |
| 48 | if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "学习并解释当前内容", "学习并解释当前内容", ""); err != nil { |
| 49 | t.Fatalf("standard turn returned %v", err) |
| 50 | } |
| 51 | if prov.call != 1 { |
| 52 | t.Fatalf("provider calls = %d, want one visible turn", prov.call) |
| 53 | } |
| 54 | if got := syntheticUserTurnCount(c.executor.Session().Snapshot()); got != 0 { |
| 55 | t.Fatalf("synthetic user turns = %d, want zero", got) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestStandardStartReplyContinuesCurrentTodoInsideVisibleTurn(t *testing.T) { |
| 60 | c, prov := manualReadinessController(t, [][]provider.Chunk{ |
| 61 | {toolCallChunk("todo-1", "todo_write", `{"todos":[{"content":"重写第 4 节","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 62 | textTurn("让我先列出待办,接下来会重写。"), |
| 63 | { |
| 64 | toolCallChunk("write-1", "write_file", `{"path":"PRD.md"}`), |
| 65 | toolCallChunk("todo-2", "todo_write", `{"todos":[{"content":"重写第 4 节","status":"completed"}]}`), |
| 66 | {Type: provider.ChunkDone}, |
| 67 | }, |
| 68 | textTurn("第 4 节已经完成重写。"), |
| 69 | }) |
| 70 | if err := c.SetQualityFloor(QualityFloorStandard); err != nil { |
| 71 | t.Fatalf("SetQualityFloor: %v", err) |
| 72 | } |
| 73 | c.executor.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "让我写完整新第 4 节。"}) |
| 74 | |
| 75 | if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "开始", "开始", "开始"); err != nil { |
| 76 | t.Fatalf("standard start reply returned %v", err) |
| 77 | } |
| 78 | if prov.call != 2 { |
| 79 | t.Fatalf("provider calls = %d, want a clean final with no ordinary todo continuation", prov.call) |
| 80 | } |
| 81 | if got := syntheticUserTurnCount(c.executor.Session().Snapshot()); got != 0 { |
| 82 | t.Fatalf("synthetic user turns = %d, want none on the ordinary Agent path", got) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestStandardStartWithoutConversationDoesNotArmTodoContinuation(t *testing.T) { |
| 87 | c, prov := manualReadinessController(t, [][]provider.Chunk{ |
| 88 | {toolCallChunk("todo-1", "todo_write", `{"todos":[{"content":"重写第 4 节","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 89 | textTurn("等待下一步。"), |
| 90 | textTurn("不应被隐藏续跑消费"), |
| 91 | }) |
| 92 | |
| 93 | if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "开始", "开始", "开始"); err != nil { |
| 94 | t.Fatalf("standard start reply returned %v", err) |
| 95 | } |
| 96 | if prov.call != 2 { |
| 97 | t.Fatalf("provider calls = %d, want no continuation without prior context", prov.call) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestStandardTodoContinuationYieldsToPendingUserWork(t *testing.T) { |
| 102 | c, prov := manualReadinessController(t, [][]provider.Chunk{ |
| 103 | {toolCallChunk("todo-1", "todo_write", `{"todos":[{"content":"重写第 4 节","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 104 | textTurn("等待下一步。"), |
| 105 | textTurn("不应被隐藏续跑消费"), |
| 106 | }) |
| 107 | c.executor.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "让我写完整新第 4 节。"}) |
| 108 | c.mu.Lock() |
| 109 | c.turns.cancelRequested = true |
| 110 | c.turns.phase = session.RuntimeCancelling |
| 111 | c.mu.Unlock() |
| 112 | |
| 113 | if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "开始", "开始", "开始"); err != nil { |
| 114 | t.Fatalf("standard start reply returned %v", err) |
| 115 | } |
| 116 | if prov.call != 2 { |
| 117 | t.Fatalf("provider calls = %d, want pending user work to preempt continuation", prov.call) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestRetiredDeliverySettingEndsOrdinaryTurnWithoutRecoveryPause(t *testing.T) { |
| 122 | c, prov := manualReadinessController(t, [][]provider.Chunk{ |
| 123 | {toolCallChunk("write", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 124 | textTurn("已完成修改,但没有执行验证。"), |
| 125 | textTurn("显式恢复才允许消费这一轮"), |
| 126 | }) |
| 127 | if err := c.SetQualityFloor(QualityFloorDelivery); err != nil { |
| 128 | t.Fatalf("SetQualityFloor: %v", err) |
| 129 | } |
| 130 | |
| 131 | if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "修改 main.go", "修改 main.go", ""); err != nil { |
| 132 | t.Fatalf("ordinary turn returned %v", err) |
| 133 | } |
| 134 | if prov.call != 2 { |
| 135 | t.Fatalf("provider calls = %d, want one tool round plus one final answer", prov.call) |
| 136 | } |
| 137 | if got := syntheticUserTurnCount(c.executor.Session().Snapshot()); got != 0 { |
| 138 | t.Fatalf("synthetic user turns = %d, want zero before explicit recovery", got) |
| 139 | } |
| 140 | } |
| 141 |