| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "reflect" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/evidence" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/tool" |
| 14 | ) |
| 15 | |
| 16 | func TestDeliveryExecutionScopeDoesNotChangeProviderRequestBytes(t *testing.T) { |
| 17 | turn := []provider.Chunk{{Type: provider.ChunkText, Text: "Here is the explanation."}, {Type: provider.ChunkDone}} |
| 18 | unscopedProvider := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{turn}} |
| 19 | scopedProvider := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{turn}} |
| 20 | reg := tool.NewRegistry() |
| 21 | reg.Add(fakeReadFileTool{}) |
| 22 | |
| 23 | unscoped := New(unscopedProvider, reg, NewSession("stable system"), Options{DeliveryProfile: true}, event.Discard) |
| 24 | scoped := New(scopedProvider, reg, NewSession("stable system"), Options{DeliveryProfile: true}, event.Discard) |
| 25 | input := "explain the current implementation" |
| 26 | if err := unscoped.Run(context.Background(), input); err != nil { |
| 27 | t.Fatalf("unscoped run: %v", err) |
| 28 | } |
| 29 | if err := scoped.Run(deliveryGoalContext("goal-stable", input), input); err != nil { |
| 30 | t.Fatalf("scoped run: %v", err) |
| 31 | } |
| 32 | if len(unscopedProvider.requests) != 1 || len(scopedProvider.requests) != 1 { |
| 33 | t.Fatalf("request counts = (%d, %d), want one each", len(unscopedProvider.requests), len(scopedProvider.requests)) |
| 34 | } |
| 35 | left, right := unscopedProvider.requests[0], scopedProvider.requests[0] |
| 36 | if !reflect.DeepEqual(left.Messages, right.Messages) { |
| 37 | t.Fatalf("Delivery scope changed provider-visible messages:\nunscoped=%+v\nscoped=%+v", left.Messages, right.Messages) |
| 38 | } |
| 39 | if !reflect.DeepEqual(left.Tools, right.Tools) { |
| 40 | t.Fatal("Delivery scope changed provider-visible tool schemas") |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func deliveryGoalContext(id, task string) context.Context { |
| 45 | return WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: id, TaskText: task}) |
| 46 | } |
| 47 | |
| 48 | func TestDeliveryGoalFinalAnswerAlwaysGatesMutationExpectation(t *testing.T) { |
| 49 | reg := tool.NewRegistry() |
| 50 | reg.Add(fakeReadFileTool{}) |
| 51 | reg.Add(fakeWriterTool{}) |
| 52 | prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{ |
| 53 | {toolCallChunk("read", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 54 | {{Type: provider.ChunkText, Text: "Investigation complete."}, {Type: provider.ChunkDone}}, |
| 55 | {{Type: provider.ChunkText, Text: "Implemented."}, {Type: provider.ChunkDone}}, |
| 56 | }} |
| 57 | a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard) |
| 58 | ctx := deliveryGoalContext("goal-1", "fix the crash in main.go") |
| 59 | // A read-only final answer is gated on the mutation expectation immediately: |
| 60 | // the host no longer defers readiness for marker-carrying turns, the Goal |
| 61 | // FSM absorbs the failure and continues with the missing requirements. |
| 62 | err := a.Run(ctx, "investigate the crash") |
| 63 | var readiness *FinalReadinessError |
| 64 | if !errors.As(err, &readiness) || !strings.Contains(readiness.Reason, "state change") { |
| 65 | t.Fatalf("read-only final answer err = %v, want mutation readiness failure", err) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestDeliveryGoalScopeCarriesSignedOffMutationAcrossTurns(t *testing.T) { |
| 70 | reg := evidenceRegistry() |
| 71 | reg.Add(fakeTool{name: "read_file", readOnly: true}) |
| 72 | prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{ |
| 73 | {toolCallChunk("criteria", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 74 | {toolCallChunk("write", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 75 | {toolCallChunk("review", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 76 | {toolCallChunk("verify", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}}, |
| 77 | {toolCallChunk("signoff", "complete_step", `{"step":"Ship main","result":"implemented","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}}, |
| 78 | {{Type: provider.ChunkText, Text: "Implementation complete."}, {Type: provider.ChunkDone}}, |
| 79 | {{Type: provider.ChunkText, Text: "Final summary."}, {Type: provider.ChunkDone}}, |
| 80 | }} |
| 81 | a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard) |
| 82 | ctx := deliveryGoalContext("goal-1", "implement main") |
| 83 | if err := a.Run(ctx, "implement the first chunk"); err != nil { |
| 84 | t.Fatalf("mutation turn: %v", err) |
| 85 | } |
| 86 | if err := a.Run(ctx, "finish the goal"); err != nil { |
| 87 | t.Fatalf("verification-only completion should reuse scoped evidence: %v", err) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestDeliveryGoalRestoredPendingMutationCompletesWithoutNewWrite(t *testing.T) { |
| 92 | // A controller rebuild or cold resume restores PendingMutation with no |
| 93 | // mutation receipt in the fresh ledger (a -1 baseline). Fresh review, |
| 94 | // verification, and sign-off receipts must finish the Goal without |
| 95 | // manufacturing another write, and the checkpoint must clear. |
| 96 | reg := evidenceRegistry() |
| 97 | reg.Add(fakeTool{name: "read_file", readOnly: true}) |
| 98 | prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{ |
| 99 | {toolCallChunk("review", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 100 | {toolCallChunk("verify", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}}, |
| 101 | {toolCallChunk("signoff", "complete_step", `{"step":"Ship main","result":"implemented","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}}, |
| 102 | {{Type: provider.ChunkText, Text: "Recovered and verified."}, {Type: provider.ChunkDone}}, |
| 103 | }} |
| 104 | a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard) |
| 105 | a.RestoreDeliveryCheckpoint(evidence.DeliveryCheckpoint{ |
| 106 | ScopeID: "goal-1", |
| 107 | CriteriaEstablished: true, |
| 108 | WorkObserved: true, |
| 109 | MutationObserved: true, |
| 110 | PendingMutation: true, |
| 111 | }) |
| 112 | if err := a.Run(deliveryGoalContext("goal-1", "implement main"), "continue the goal"); err != nil { |
| 113 | t.Fatalf("restored pending mutation should complete with fresh review/verification/sign-off: %v", err) |
| 114 | } |
| 115 | if cp := a.DeliveryCheckpoint(); cp.PendingMutation { |
| 116 | t.Fatalf("checkpoint = %+v, want PendingMutation cleared after sign-off", cp) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestDeliveryGoalNewMutationInvalidatesPriorSignoff(t *testing.T) { |
| 121 | reg := evidenceRegistry() |
| 122 | reg.Add(fakeTool{name: "read_file", readOnly: true}) |
| 123 | prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{ |
| 124 | {toolCallChunk("criteria-1", "todo_write", `{"todos":[{"content":"Ship main","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 125 | {toolCallChunk("write-1", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 126 | {toolCallChunk("review-1", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 127 | {toolCallChunk("verify-1", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}}, |
| 128 | {toolCallChunk("signoff-1", "complete_step", `{"step":"Ship main","result":"implemented","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}}, |
| 129 | {{Type: provider.ChunkText, Text: "First chunk done."}, {Type: provider.ChunkDone}}, |
| 130 | {toolCallChunk("criteria-2", "todo_write", `{"todos":[{"content":"Polish main","status":"in_progress"}]}`), {Type: provider.ChunkDone}}, |
| 131 | {toolCallChunk("write-2", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}}, |
| 132 | {{Type: provider.ChunkText, Text: "All done."}, {Type: provider.ChunkDone}}, |
| 133 | }} |
| 134 | a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard) |
| 135 | ctx := deliveryGoalContext("goal-1", "implement main") |
| 136 | if err := a.Run(ctx, "implement the first chunk"); err != nil { |
| 137 | t.Fatalf("first turn: %v", err) |
| 138 | } |
| 139 | err := a.Run(ctx, "polish and finish") |
| 140 | var readiness *FinalReadinessError |
| 141 | if !errors.As(err, &readiness) || !strings.Contains(readiness.Reason, "verification") { |
| 142 | t.Fatalf("new mutation err = %v, want fresh verification failure", err) |
| 143 | } |
| 144 | } |
| 145 |