| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/control" |
| 11 | ) |
| 12 | |
| 13 | type initialGoalSubmitRecorder struct { |
| 14 | control.SessionAPI |
| 15 | display string |
| 16 | input string |
| 17 | invocations []control.InvocationRequest |
| 18 | } |
| 19 | |
| 20 | func TestSubmitInitialGoalStopsBeforeProviderWhenPersistenceFails(t *testing.T) { |
| 21 | app := testAppWithOrderedTabs(t, "a", "a") |
| 22 | base := control.New(control.Options{Label: "test"}) |
| 23 | defer base.Close() |
| 24 | recorder := &initialGoalSubmitRecorder{SessionAPI: base} |
| 25 | tab := app.tabs["a"] |
| 26 | tab.Ctrl = &rejectingGoalSession{SessionAPI: recorder, err: errors.New("disk full")} |
| 27 | |
| 28 | _, err := app.SubmitInitialGoalToTab( |
| 29 | tab.ID, "ship the fix", "ship the fix", "ship the fix", nil, |
| 30 | "normal", "ask", |
| 31 | ) |
| 32 | if err == nil || !strings.Contains(err.Error(), "disk full") { |
| 33 | t.Fatalf("SubmitInitialGoalToTab error = %v, want persistence failure", err) |
| 34 | } |
| 35 | if recorder.input != "" || recorder.display != "" { |
| 36 | t.Fatalf("provider submission escaped failed goal mutation: display=%q input=%q", recorder.display, recorder.input) |
| 37 | } |
| 38 | if tab.goal != "" { |
| 39 | t.Fatalf("tab goal = %q, want unpublished", tab.goal) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | func TestSubmitInitialGoalRejectsMissingImageBeforeGoalMutation(t *testing.T) { |
| 44 | workspace := t.TempDir() |
| 45 | ctrl := control.New(control.Options{WorkspaceRoot: workspace}) |
| 46 | t.Cleanup(ctrl.Close) |
| 47 | tab := &WorkspaceTab{ |
| 48 | ID: "a", |
| 49 | Scope: "project", |
| 50 | WorkspaceRoot: workspace, |
| 51 | TopicID: "topic_a", |
| 52 | TopicTitle: "A", |
| 53 | Ready: true, |
| 54 | Ctrl: ctrl, |
| 55 | disabledMCP: map[string]ServerView{}, |
| 56 | } |
| 57 | app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, tabOrder: []string{"a"}, activeTabID: "a"} |
| 58 | |
| 59 | _, err := app.SubmitInitialGoalToTab( |
| 60 | tab.ID, |
| 61 | "inspect the image", |
| 62 | "inspect missing.png", |
| 63 | "inspect @.reasonix/attachments/missing.png", |
| 64 | nil, |
| 65 | "normal", |
| 66 | "ask", |
| 67 | ) |
| 68 | if err == nil || err.Error() != "reasonix_error:image_attachment_unreadable" { |
| 69 | t.Fatalf("error = %v, want stable image failure", err) |
| 70 | } |
| 71 | if tab.goal != "" || ctrl.Goal() != "" { |
| 72 | t.Fatalf("rejected image mutated goal: tab=%q controller=%q", tab.goal, ctrl.Goal()) |
| 73 | } |
| 74 | if ctrl.Running() { |
| 75 | t.Fatal("rejected image started a turn") |
| 76 | } |
| 77 | if _, statErr := os.Stat(filepath.Join(workspace, ".reasonix", "attachments")); !os.IsNotExist(statErr) { |
| 78 | t.Fatalf("failed image read created attachment directory: %v", statErr) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func (r *initialGoalSubmitRecorder) SubmitDisplay(display, input string) { |
| 83 | r.display = display |
| 84 | r.input = input |
| 85 | } |
| 86 | |
| 87 | func (r *initialGoalSubmitRecorder) SubmitInvocationDisplay( |
| 88 | display, input string, |
| 89 | invocations []control.InvocationRequest, |
| 90 | ) { |
| 91 | r.display = display |
| 92 | r.input = input |
| 93 | r.invocations = append([]control.InvocationRequest(nil), invocations...) |
| 94 | } |
| 95 | |
| 96 | func TestSubmitInitialGoalAcceptsCurrentLocalTarget(t *testing.T) { |
| 97 | app := testAppWithOrderedTabs(t, "a", "a") |
| 98 | ctrl := control.New(control.Options{Label: "test"}) |
| 99 | defer ctrl.Close() |
| 100 | recorder := &initialGoalSubmitRecorder{SessionAPI: ctrl} |
| 101 | tab := app.tabs["a"] |
| 102 | tab.Ctrl = recorder |
| 103 | |
| 104 | _, err := app.SubmitInitialGoalToTab( |
| 105 | tab.ID, |
| 106 | "ship the fix", |
| 107 | "/ui-ux-pro-max ship the fix", |
| 108 | "ship the fix", |
| 109 | []InvocationRequest{{Name: "ui-ux-pro-max", Kind: "skill", Offset: 4}}, |
| 110 | "normal", |
| 111 | "ask", |
| 112 | ) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if tab.goal != "ship the fix" { |
| 117 | t.Fatalf("tab goal = %q, want %q", tab.goal, "ship the fix") |
| 118 | } |
| 119 | if got := ctrl.Goal(); got != "ship the fix" { |
| 120 | t.Fatalf("controller goal = %q, want %q", got, "ship the fix") |
| 121 | } |
| 122 | if recorder.display != "/ui-ux-pro-max ship the fix" || recorder.input != "ship the fix" { |
| 123 | t.Fatalf("recorded submit = display %q input %q", recorder.display, recorder.input) |
| 124 | } |
| 125 | if len(recorder.invocations) != 1 { |
| 126 | t.Fatalf("recorded invocations = %+v, want one", recorder.invocations) |
| 127 | } |
| 128 | if got := recorder.invocations[0]; got.Name != "ui-ux-pro-max" || got.Kind != "skill" || got.Offset != 4 { |
| 129 | t.Fatalf("recorded invocation = %+v", got) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestSubmitInitialGoalAppliesToolApprovalProfileBeforeSubmit(t *testing.T) { |
| 134 | app := testAppWithOrderedTabs(t, "a", "a") |
| 135 | ctrl := control.New(control.Options{Label: "test"}) |
| 136 | defer ctrl.Close() |
| 137 | recorder := &initialGoalSubmitRecorder{SessionAPI: ctrl} |
| 138 | tab := app.tabs["a"] |
| 139 | tab.Ctrl = recorder |
| 140 | |
| 141 | _, err := app.SubmitInitialGoalToTab( |
| 142 | tab.ID, |
| 143 | "ship with YOLO", |
| 144 | "ship with YOLO", |
| 145 | "ship with YOLO", |
| 146 | nil, |
| 147 | "normal", |
| 148 | string(control.ToolApprovalYolo), |
| 149 | ) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | if tab.toolApprovalMode != string(control.ToolApprovalYolo) { |
| 154 | t.Fatalf("tab approval mode = %q, want %q", tab.toolApprovalMode, control.ToolApprovalYolo) |
| 155 | } |
| 156 | if got := recorder.ToolApprovalMode(); got != string(control.ToolApprovalYolo) { |
| 157 | t.Fatalf("controller approval mode = %q, want %q", got, control.ToolApprovalYolo) |
| 158 | } |
| 159 | if recorder.input != "ship with YOLO" { |
| 160 | t.Fatalf("recorded input = %q, want first Goal input", recorder.input) |
| 161 | } |
| 162 | } |
| 163 |