| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | tea "charm.land/bubbletea/v2" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/provider" |
| 15 | ) |
| 16 | |
| 17 | type maintenanceCLIController struct { |
| 18 | control.SessionAPI |
| 19 | cancelCalls int |
| 20 | submissions []string |
| 21 | result control.SubmitResult |
| 22 | } |
| 23 | |
| 24 | type cliBlockingSummaryProvider struct { |
| 25 | started chan struct{} |
| 26 | cancelled chan struct{} |
| 27 | } |
| 28 | |
| 29 | func (p *cliBlockingSummaryProvider) Name() string { return "cli-blocking-summary" } |
| 30 | |
| 31 | func (p *cliBlockingSummaryProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) { |
| 32 | close(p.started) |
| 33 | <-ctx.Done() |
| 34 | close(p.cancelled) |
| 35 | return nil, ctx.Err() |
| 36 | } |
| 37 | |
| 38 | func (c *maintenanceCLIController) Cancel() { c.cancelCalls++ } |
| 39 | |
| 40 | func (c *maintenanceCLIController) SubmitDisplayWithResult(display, input string) control.SubmitResult { |
| 41 | c.submissions = append(c.submissions, display+"\x00"+input) |
| 42 | return c.result |
| 43 | } |
| 44 | |
| 45 | func maintenanceEvent(id, activity, status string) agentEventMsg { |
| 46 | return agentEventMsg(event.Event{ |
| 47 | Kind: event.SessionOperation, |
| 48 | SessionOperation: &event.SessionOperationInfo{ |
| 49 | OperationID: id, |
| 50 | Kind: "compact", |
| 51 | Activity: activity, |
| 52 | Status: status, |
| 53 | }, |
| 54 | }) |
| 55 | } |
| 56 | |
| 57 | func TestSessionOperationOwnsMaintenanceWithoutStartingTurn(t *testing.T) { |
| 58 | ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})} |
| 59 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 60 | |
| 61 | next, _ := m.Update(maintenanceEvent("op-1", "running", "running")) |
| 62 | m = next.(chatTUI) |
| 63 | if m.state != tuiIdle { |
| 64 | t.Fatalf("maintenance changed ordinary turn state = %v, want idle", m.state) |
| 65 | } |
| 66 | if m.maintenance == nil || m.maintenance.OperationID != "op-1" || m.maintenance.Activity != "running" { |
| 67 | t.Fatalf("maintenance state = %+v, want running op-1", m.maintenance) |
| 68 | } |
| 69 | if !m.runStart.IsZero() || m.elapsedTickGeneration != 0 { |
| 70 | t.Fatalf("maintenance started ordinary turn timing: start=%v generation=%d", m.runStart, m.elapsedTickGeneration) |
| 71 | } |
| 72 | |
| 73 | next, _ = m.Update(maintenanceEvent("op-1", "finalizing", "finalizing")) |
| 74 | m = next.(chatTUI) |
| 75 | if m.maintenance == nil || m.maintenance.Activity != "finalizing" { |
| 76 | t.Fatalf("finalizing operation state = %+v", m.maintenance) |
| 77 | } |
| 78 | |
| 79 | next, _ = m.Update(maintenanceEvent("op-1", "finalizing", "completed")) |
| 80 | m = next.(chatTUI) |
| 81 | if m.maintenance != nil { |
| 82 | t.Fatalf("terminal operation left maintenance active: %+v", m.maintenance) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestTerminalSessionOperationCannotRegressToCancelling(t *testing.T) { |
| 87 | ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})} |
| 88 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 89 | |
| 90 | next, _ := m.Update(maintenanceEvent("op-1", "running", "running")) |
| 91 | m = next.(chatTUI) |
| 92 | next, _ = m.Update(maintenanceEvent("op-1", "finalizing", "completed")) |
| 93 | m = next.(chatTUI) |
| 94 | terminalCard := m.transcript[m.maintenanceTranscriptIdx] |
| 95 | |
| 96 | next, _ = m.Update(maintenanceEvent("op-1", "cancelling", "cancelling")) |
| 97 | m = next.(chatTUI) |
| 98 | if m.maintenance != nil { |
| 99 | t.Fatalf("late cancelling event reactivated maintenance: %+v", m.maintenance) |
| 100 | } |
| 101 | if got := m.transcript[m.maintenanceTranscriptIdx]; got != terminalCard { |
| 102 | t.Fatalf("late cancelling event replaced terminal card:\n%s", got) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestSessionOperationRevisionRejectsOlderProgress(t *testing.T) { |
| 107 | ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})} |
| 108 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 109 | |
| 110 | newer := event.Event{Kind: event.SessionOperation, SessionOperation: &event.SessionOperationInfo{ |
| 111 | OperationID: "op-1", OperationRevision: 3, RuntimeEpoch: "epoch-1", |
| 112 | Kind: "compact", Activity: "finalizing", Status: "finalizing", Detail: "keep", |
| 113 | }} |
| 114 | older := event.Event{Kind: event.SessionOperation, SessionOperation: &event.SessionOperationInfo{ |
| 115 | OperationID: "op-1", OperationRevision: 2, RuntimeEpoch: "epoch-1", |
| 116 | Kind: "compact", Activity: "cancelling", Status: "cancelling", |
| 117 | }} |
| 118 | next, _ := m.Update(agentEventMsg(newer)) |
| 119 | m = next.(chatTUI) |
| 120 | next, _ = m.Update(agentEventMsg(older)) |
| 121 | m = next.(chatTUI) |
| 122 | |
| 123 | if m.maintenance == nil || m.maintenance.OperationRevision != 3 || m.maintenance.Activity != "finalizing" { |
| 124 | t.Fatalf("older progress replaced newer maintenance state: %+v", m.maintenance) |
| 125 | } |
| 126 | if m.maintenance.Detail != "keep" { |
| 127 | t.Fatalf("older progress cleared detail: %+v", m.maintenance) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestSessionOperationUpdatesOneCompactionCard(t *testing.T) { |
| 132 | ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})} |
| 133 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 134 | |
| 135 | next, _ := m.Update(maintenanceEvent("op-1", "running", "running")) |
| 136 | m = next.(chatTUI) |
| 137 | cardCount := len(m.transcript) |
| 138 | cardIndex := m.maintenanceTranscriptIdx |
| 139 | |
| 140 | next, _ = m.Update(maintenanceEvent("op-1", "cancelling", "cancelling")) |
| 141 | m = next.(chatTUI) |
| 142 | if len(m.transcript) != cardCount || m.maintenanceTranscriptIdx != cardIndex { |
| 143 | t.Fatalf("cancelling inserted another card: count=%d index=%d", len(m.transcript), m.maintenanceTranscriptIdx) |
| 144 | } |
| 145 | |
| 146 | done := event.Event{Kind: event.SessionOperation, SessionOperation: &event.SessionOperationInfo{ |
| 147 | OperationID: "op-1", Kind: "compact", Activity: "finalizing", Status: "completed", |
| 148 | Applied: true, InputTokens: 12000, ResultTokens: 3000, Messages: 6, Summary: "retained summary", |
| 149 | }} |
| 150 | next, _ = m.Update(agentEventMsg(done)) |
| 151 | m = next.(chatTUI) |
| 152 | if len(m.transcript) != cardCount || m.maintenanceTranscriptIdx != cardIndex { |
| 153 | t.Fatalf("completion inserted another card: count=%d index=%d", len(m.transcript), m.maintenanceTranscriptIdx) |
| 154 | } |
| 155 | card := m.transcript[cardIndex] |
| 156 | for _, want := range []string{"retained summary", "12.0K", "3.0K"} { |
| 157 | if !strings.Contains(card, want) { |
| 158 | t.Fatalf("completed card missing %q:\n%s", want, card) |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestEscCancelsMaintenanceOnceAndPreservesDraft(t *testing.T) { |
| 164 | ctrl := &maintenanceCLIController{SessionAPI: newOwnedTestController(t, control.Options{})} |
| 165 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 166 | m.input.SetValue("keep this draft") |
| 167 | m.pastedBlocks = []pastedBlock{{label: "[paste #1]", text: "payload"}} |
| 168 | |
| 169 | next, _ := m.Update(maintenanceEvent("op-1", "running", "running")) |
| 170 | m = next.(chatTUI) |
| 171 | next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) |
| 172 | m = next.(chatTUI) |
| 173 | |
| 174 | if ctrl.cancelCalls != 1 { |
| 175 | t.Fatalf("Esc cancel calls = %d, want 1", ctrl.cancelCalls) |
| 176 | } |
| 177 | if got := m.input.Value(); got != "keep this draft" { |
| 178 | t.Fatalf("Esc changed maintenance draft to %q", got) |
| 179 | } |
| 180 | if len(m.pastedBlocks) != 1 || m.pastedBlocks[0].text != "payload" { |
| 181 | t.Fatalf("Esc changed maintenance paste state: %+v", m.pastedBlocks) |
| 182 | } |
| 183 | if m.maintenance == nil || m.maintenance.Activity != "cancelling" { |
| 184 | t.Fatalf("Esc maintenance state = %+v, want cancelling", m.maintenance) |
| 185 | } |
| 186 | |
| 187 | next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) |
| 188 | m = next.(chatTUI) |
| 189 | if ctrl.cancelCalls != 1 { |
| 190 | t.Fatalf("repeated Esc cancel calls = %d, want idempotent 1", ctrl.cancelCalls) |
| 191 | } |
| 192 | if got := m.input.Value(); got != "keep this draft" { |
| 193 | t.Fatalf("repeated Esc changed maintenance draft to %q", got) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | func TestCompactSlashUsesRegisteredManagementSubmission(t *testing.T) { |
| 198 | ctrl := &maintenanceCLIController{ |
| 199 | SessionAPI: newOwnedTestController(t, control.Options{}), |
| 200 | result: control.SubmitResult{ |
| 201 | Disposition: control.SubmitManagementHandled, |
| 202 | OperationID: "op-registered", |
| 203 | }, |
| 204 | } |
| 205 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 206 | |
| 207 | if cmd := m.runSlashCommand("/compact retain tests"); cmd != nil { |
| 208 | t.Fatal("registered /compact should not return a long-running TUI command") |
| 209 | } |
| 210 | if len(ctrl.submissions) != 1 || ctrl.submissions[0] != "/compact retain tests\x00/compact retain tests" { |
| 211 | t.Fatalf("management submissions = %#v", ctrl.submissions) |
| 212 | } |
| 213 | if m.maintenance == nil || m.maintenance.OperationID != "op-registered" { |
| 214 | t.Fatalf("registered maintenance state = %+v", m.maintenance) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func TestCompactCommandEscCancelsRealMaintenanceAndKeepsDraft(t *testing.T) { |
| 219 | providerStub := &cliBlockingSummaryProvider{ |
| 220 | started: make(chan struct{}), |
| 221 | cancelled: make(chan struct{}), |
| 222 | } |
| 223 | sess := agent.NewSession("sys") |
| 224 | for range 8 { |
| 225 | sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("question ", 300)}) |
| 226 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("answer ", 300)}) |
| 227 | } |
| 228 | events := make(chan event.Event, 128) |
| 229 | sink := event.FuncSink(func(e event.Event) { events <- e }) |
| 230 | exec := agent.New(providerStub, nil, sess, agent.Options{ContextWindow: 32000}, sink) |
| 231 | ctrl := newOwnedTestController(t, control.Options{Executor: exec, Sink: sink}) |
| 232 | m := newChatTUI(ctrl, "", events, 80) |
| 233 | |
| 234 | if cmd := m.runSlashCommand("/compact retain tests"); cmd != nil { |
| 235 | t.Fatal("registered /compact returned a blocking command") |
| 236 | } |
| 237 | select { |
| 238 | case <-providerStub.started: |
| 239 | case <-time.After(3 * time.Second): |
| 240 | t.Fatal("summary provider did not start") |
| 241 | } |
| 242 | |
| 243 | deadline := time.After(3 * time.Second) |
| 244 | for len(m.maintenanceLatest) == 0 { |
| 245 | select { |
| 246 | case e := <-events: |
| 247 | next, _ := m.Update(agentEventMsg(e)) |
| 248 | m = next.(chatTUI) |
| 249 | case <-deadline: |
| 250 | t.Fatal("SessionOperation start event was not dispatched to the TUI") |
| 251 | } |
| 252 | } |
| 253 | m.input.SetValue("draft queued during compaction") |
| 254 | next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) |
| 255 | m = next.(chatTUI) |
| 256 | |
| 257 | select { |
| 258 | case <-providerStub.cancelled: |
| 259 | case <-time.After(3 * time.Second): |
| 260 | t.Fatal("Esc did not cancel the real summary request") |
| 261 | } |
| 262 | if got := m.input.Value(); got != "draft queued during compaction" { |
| 263 | t.Fatalf("Esc changed draft to %q", got) |
| 264 | } |
| 265 | if m.state != tuiIdle { |
| 266 | t.Fatalf("maintenance changed ordinary turn state to %v", m.state) |
| 267 | } |
| 268 | } |
| 269 |