mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* feat(tui): /loop foundation — command parsing, loop state, safety bounds First slice of the /loop feature (#501): the self-contained core with no TUI wiring yet. parseLoopCommand handles the leading-interval, trailing-"every", bare-self-paced, and list/stop forms; loopState models one session-scoped loop with doom-loop + iteration-cap fields; interval/self-pace clamps and duration formatting round it out, with unit tests. Because Zero drives loops off a Go timer rather than a cron backend, arbitrary intervals (90s, 7m) are supported with no whitelist limitation. * feat(tui): wire /loop into the turn lifecycle — dispatch, idle tick, continuation Second slice of #501. Adds the controller and wires it in: - /loop command registered (commands.go) and dispatched to handleLoopCommand. - Loop state on the model (loops, activeLoopID, loopSeq/loopTicking poll guards, loopNextWake/loopDone self-pace channel). - An idle poll tick (loopTickMsg, ~1s) fires the earliest due loop only when the session is idle — mirrors the launchQueuedMessageIfReady guard so loops and a user's queued prompt never collide; a self-perpetuating tea.Tick that stops when no loops remain. - The agentResponseMsg completion seam advances a loop iteration (schedule next wake for fixed, model delay for self-paced) with doom-loop, iteration-cap, and done stop conditions, restarting the ticker if other loops remain. - /loop list|stop|stop all, footer summary + list rendering, and a guard that only a prompt or custom /command may loop (not a built-in). Controller tests cover start/advance/self-paced-delay/done/doom/cap/stop/busy. * feat(tui): /loop self-paced adaptive cadence + footer summary + /clear note Third slice of #501. - Self-paced loops now use an adaptive cadence (2m early -> 30m heartbeat as iterations accrue), with the loopNextWake channel kept as an override for a future explicit model signal. - Persistent footer segment "↻ N loops · next 3:05pm" while loops are active. - /clear notes that loops keep running (it wipes the transcript, not the session) so a loop never silently fires into a cleared screen. - Tests for adaptive cadence + footer summary. * test(tui): drive /loop end-to-end through Update (submit + stale tick) * docs(readme): add /loop to the slash-command table * fix(tui): stop looping on repeated failure + clear loop tag on cancel Addresses CodeRabbit review on #502: - advanceLoop now counts consecutive failed iterations (loopMaxFailures=3) and stops the loop, so a loop whose turns keep erroring no longer churns to the iteration cap (the doom guard only sees identical non-empty answers). - cancelRun clears activeLoopID and re-arms the interrupted loop for its next cadence, so a cancelled iteration is not left running forever and the next unrelated turn is not misattributed as that loop's completion. * fix(tui): finish /loop v1 — real self-paced completion, session scope, leave gate Response to the review. The self-paced path was the real gap: it advertised model-chosen completion but nothing ever wrote the done/next-wake fields, so it was just adaptive polling dressed up. Replaced that with an actual control channel — each self-paced iteration runs a short maintenance playbook and ends its reply with a control line (LOOP: DONE / LOOP: CONTINUE [interval]) that advanceLoop parses to stop the loop or pick the next wake. The two dead model fields are gone; done and next-wake now come from the model's output. Rest of the review: - loops stop on a session switch (/new, /resume) instead of firing the previous conversation's prompt into the new one - validateLoopTarget resolves custom /commands up front and rejects unknowns, so a mistyped `/loop 5m /nope` isn't scheduled and then re-sent as literal text - a one-shot confirm gates /clear and /quit while loops are active - age-based auto-expire (24h) next to the iteration cap - the two CodeRabbit test nits (HasPrefix footer check, bare `/loop stop` on a single loop) Hardening from a self-review pass over the above: - normalize CRLF and allow a trailing note on the control line, so `LOOP: DONE — all clean` and \r\n replies still stop the loop - disarm the leave-confirm before the queue/exit early returns, so an interposed prompt can't leave it falsely armed - a loop no longer fires behind an open picker/modal; a run launched behind the /resume picker would otherwise complete into whatever session you switch to * fix(tui): address /loop review round 2 — ticker re-arm, self-paced command, resume guard - stopLoop re-arms the poll ticker after removing one loop, so stopping one of several no longer strands the rest with no tick scheduled (jatmn). - a bare self-paced /command is rejected with a pointer to the interval form: self-paced drives a free-form goal via the playbook + LOOP: control line, which a custom command's expanded template never carries, so it would silently degrade to adaptive polling (jatmn). - /resume only tears loops down on an actual session-id change; /resume latest or /resume <currentID> can resolve to the already-active session, whose loops are its own, not a "previous" session's (CodeRabbit). - tests: skipped-behind-modal loop stays scheduled, footer carries the next-wake time, the confirmed /clear actually wipes the transcript, plus multi-loop stop keeping the ticker alive and self-paced-/command rejection. * docs(readme): clarify /loop runs a prompt or custom command, not built-ins
461 lines
16 KiB
Go
461 lines
16 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/Gitlawb/zero/internal/usercommands"
|
|
)
|
|
|
|
func loopTestModel(t *testing.T, now time.Time) model {
|
|
t.Helper()
|
|
m := newModel(context.Background(), Options{})
|
|
m.now = func() time.Time { return now }
|
|
return m
|
|
}
|
|
|
|
func startFixedLoop(m model, prompt string, interval time.Duration) model {
|
|
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt, interval: interval})
|
|
return m
|
|
}
|
|
|
|
func TestStartLoopRegistersAndSchedules(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "check the build", 5*time.Minute)
|
|
|
|
if len(m.loops) != 1 {
|
|
t.Fatalf("want 1 loop, got %d", len(m.loops))
|
|
}
|
|
l := m.loops[0]
|
|
if l.mode != loopModeFixed || l.interval != 5*time.Minute || l.prompt != "check the build" {
|
|
t.Fatalf("unexpected loop %+v", l)
|
|
}
|
|
// First iteration is due immediately (nextRunAt == now).
|
|
if !l.due(now) {
|
|
t.Errorf("first iteration should be due at creation")
|
|
}
|
|
if !m.loopTicking {
|
|
t.Errorf("starting a loop should start the poll ticker")
|
|
}
|
|
}
|
|
|
|
func TestAdvanceLoopSchedulesNextWake(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "poll", 5*time.Minute)
|
|
id := m.loops[0].id
|
|
|
|
m = m.advanceLoop(id, "did some work", nil)
|
|
l := m.loops[0]
|
|
if l.iteration != 1 {
|
|
t.Fatalf("iteration = %d, want 1", l.iteration)
|
|
}
|
|
if !l.nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
|
t.Fatalf("nextRunAt = %v, want +5m", l.nextRunAt)
|
|
}
|
|
}
|
|
|
|
func TestSelfPacedUsesModelDelay(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "keep tidying"})
|
|
id := m.loops[0].id
|
|
// The model carries its chosen next wake in the reply's control line.
|
|
m = m.advanceLoop(id, "made progress\nLOOP: CONTINUE 10m", nil)
|
|
if len(m.loops) == 0 {
|
|
t.Fatal("a CONTINUE control line should keep the loop running")
|
|
}
|
|
if !m.loops[0].nextRunAt.Equal(now.Add(10 * time.Minute)) {
|
|
t.Fatalf("self-paced nextRunAt = %v, want +10m", m.loops[0].nextRunAt)
|
|
}
|
|
}
|
|
|
|
func TestLoopDoneStops(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "finish the task"})
|
|
id := m.loops[0].id
|
|
m = m.advanceLoop(id, "all done\nLOOP: DONE", nil)
|
|
if len(m.loops) != 0 {
|
|
t.Fatalf("loop should stop when the task reports done, %d remain", len(m.loops))
|
|
}
|
|
}
|
|
|
|
func TestFixedLoopIgnoresControlLine(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "watch CI", time.Minute)
|
|
id := m.loops[0].id
|
|
// A fixed-interval loop never runs the playbook, so a stray control line in the
|
|
// output is not a completion signal — it keeps its wall-clock cadence.
|
|
m = m.advanceLoop(id, "LOOP: DONE", nil)
|
|
if len(m.loops) != 1 {
|
|
t.Fatal("a fixed-interval loop should ignore a control line and keep running")
|
|
}
|
|
if !m.loops[0].nextRunAt.Equal(now.Add(time.Minute)) {
|
|
t.Fatalf("fixed nextRunAt = %v, want +1m", m.loops[0].nextRunAt)
|
|
}
|
|
}
|
|
|
|
func TestLoopExpiresByAge(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "forgotten", time.Minute)
|
|
id := m.loops[0].id
|
|
m.loops[0].createdAt = now.Add(-loopMaxAge - time.Hour) // created past the age cap
|
|
m = m.advanceLoop(id, "still going", nil)
|
|
if len(m.loops) != 0 {
|
|
t.Fatal("a loop past its age cap should auto-expire")
|
|
}
|
|
}
|
|
|
|
func TestLoopDoomGuardStops(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "spin", time.Minute)
|
|
id := m.loops[0].id
|
|
for i := 0; i < loopDoomThreshold+1 && len(m.loops) > 0; i++ {
|
|
m = m.advanceLoop(id, "identical answer", nil)
|
|
}
|
|
if len(m.loops) != 0 {
|
|
t.Fatalf("doom guard should stop a loop repeating identical results")
|
|
}
|
|
}
|
|
|
|
func TestLoopIterationCapStops(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "bounded", time.Minute)
|
|
m.loops[0].maxIter = 3
|
|
id := m.loops[0].id
|
|
for i := 0; i < 3 && len(m.loops) > 0; i++ {
|
|
m = m.advanceLoop(id, "answer "+string(rune('a'+i)), nil) // distinct answers so doom doesn't fire
|
|
}
|
|
if len(m.loops) != 0 {
|
|
t.Fatalf("loop should stop at its iteration cap")
|
|
}
|
|
}
|
|
|
|
func TestStopLoopByID(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "a", time.Minute)
|
|
m = startFixedLoop(m, "b", time.Minute)
|
|
id := m.loops[0].id
|
|
m, cmd := m.stopLoop(id)
|
|
if len(m.loops) != 1 || m.findLoop(id) != nil {
|
|
t.Fatalf("stopLoop(%s) should remove exactly that loop", id)
|
|
}
|
|
// Stopping one of several loops must keep the poll ticker alive for the rest:
|
|
// removeLoop kills the ticker, so stopLoop has to re-arm it (jatmn).
|
|
if cmd == nil || !m.loopTicking {
|
|
t.Fatal("stopping one of several loops must leave the remaining loop scheduled (ticker re-armed)")
|
|
}
|
|
// Bare stop with a single active loop stops that sole loop (parseLoopCommand
|
|
// leaves the target empty and stopLoop special-cases the one-loop case).
|
|
soleID := m.loops[0].id
|
|
m, _ = m.stopLoop("")
|
|
if len(m.loops) != 0 || m.findLoop(soleID) != nil {
|
|
t.Fatalf("bare stop should remove the sole active loop")
|
|
}
|
|
m = startFixedLoop(m, "c", time.Minute)
|
|
m = startFixedLoop(m, "d", time.Minute)
|
|
m, _ = m.stopAllLoops()
|
|
if len(m.loops) != 0 {
|
|
t.Fatalf("stopAllLoops should clear all loops")
|
|
}
|
|
}
|
|
|
|
func TestFireDueLoopSkipsWhenBusy(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "x", time.Minute)
|
|
m.pending = true // a turn is in flight
|
|
got, cmd := m.fireDueLoopIfIdle()
|
|
if got.activeLoopID != "" || cmd != nil {
|
|
t.Fatalf("a due loop must not fire while a turn is pending")
|
|
}
|
|
}
|
|
|
|
func TestFireDueLoopSkipsBehindModal(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "poll", time.Minute) // due immediately
|
|
m.picker = &commandPicker{} // e.g. an open /resume session picker
|
|
got, cmd := m.fireDueLoopIfIdle()
|
|
if got.activeLoopID != "" || cmd != nil {
|
|
t.Fatal("a due loop must not launch a run behind an open picker/modal — it would complete into whatever session the user switches to")
|
|
}
|
|
if got.loops[0].nextRunAt.IsZero() {
|
|
t.Fatal("a loop skipped because a modal is open must stay scheduled, not have its next run cleared")
|
|
}
|
|
}
|
|
|
|
func TestAdaptiveSelfPaceDelayWidensOverTime(t *testing.T) {
|
|
if adaptiveSelfPaceDelay(0) >= adaptiveSelfPaceDelay(5) {
|
|
t.Error("early iterations should check back sooner than later ones")
|
|
}
|
|
if adaptiveSelfPaceDelay(20) != 30*time.Minute {
|
|
t.Errorf("a mature loop should settle to a 30m heartbeat, got %v", adaptiveSelfPaceDelay(20))
|
|
}
|
|
}
|
|
|
|
func TestLoopFooterSummary(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 15, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
if m.loopFooterSummary() != "" {
|
|
t.Error("no loops -> empty footer summary")
|
|
}
|
|
m = startFixedLoop(m, "a", 5*time.Minute)
|
|
m.loops[0].nextRunAt = now.Add(5 * time.Minute)
|
|
got := m.loopFooterSummary()
|
|
wantWake := "next " + now.Add(5*time.Minute).Format("3:04pm")
|
|
if !strings.HasPrefix(got, "1 loop") || !strings.Contains(got, wantWake) {
|
|
t.Fatalf("footer summary = %q, want it to start with a loop count and contain %q", got, wantWake)
|
|
}
|
|
}
|
|
|
|
func TestLoopCommandThroughSubmit(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m.input.SetValue("/loop 5m tidy the imports")
|
|
updated, _ := m.Update(testKey(tea.KeyEnter))
|
|
next := updated.(model)
|
|
if len(next.loops) != 1 {
|
|
t.Fatalf("submitting /loop should create a loop, got %d", len(next.loops))
|
|
}
|
|
if next.loops[0].mode != loopModeFixed || next.loops[0].interval != 5*time.Minute {
|
|
t.Fatalf("unexpected loop from submit: %+v", next.loops[0])
|
|
}
|
|
if !next.loopTicking {
|
|
t.Fatalf("submitting /loop should start the poll ticker")
|
|
}
|
|
|
|
// /loop stop all through the same path clears it.
|
|
next.input.SetValue("/loop stop all")
|
|
updated2, _ := next.Update(testKey(tea.KeyEnter))
|
|
final := updated2.(model)
|
|
if len(final.loops) != 0 {
|
|
t.Fatalf("/loop stop all should clear loops, got %d", len(final.loops))
|
|
}
|
|
}
|
|
|
|
func TestStaleLoopTickIgnored(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "x", time.Minute)
|
|
m.loopSeq = 7
|
|
// A tick from before the seq changed must be dropped and stop the ticker.
|
|
updated, cmd := m.Update(loopTickMsg{seq: 3})
|
|
next := updated.(model)
|
|
if next.loopTicking {
|
|
t.Error("a stale loop tick should stop the ticker")
|
|
}
|
|
if cmd != nil {
|
|
t.Error("a stale loop tick should not reschedule")
|
|
}
|
|
}
|
|
|
|
func TestLoopStopsAfterConsecutiveFailures(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "flaky", time.Minute)
|
|
id := m.loops[0].id
|
|
boom := errors.New("boom")
|
|
for i := 0; i < loopMaxFailures && len(m.loops) > 0; i++ {
|
|
m = m.advanceLoop(id, "", boom)
|
|
}
|
|
if len(m.loops) != 0 {
|
|
t.Fatalf("loop should stop after %d consecutive failed iterations", loopMaxFailures)
|
|
}
|
|
}
|
|
|
|
func TestLoopFailureCounterResetsOnSuccess(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "recovers", time.Minute)
|
|
id := m.loops[0].id
|
|
m = m.advanceLoop(id, "", errors.New("x"))
|
|
m = m.advanceLoop(id, "", errors.New("x"))
|
|
m = m.advanceLoop(id, "back to healthy", nil) // a success resets the streak
|
|
if len(m.loops) != 1 {
|
|
t.Fatalf("a recovered failure streak should not stop the loop")
|
|
}
|
|
if m.loops[0].failRun != 0 {
|
|
t.Fatalf("a successful iteration should reset failRun, got %d", m.loops[0].failRun)
|
|
}
|
|
}
|
|
|
|
func TestCancelRunClearsActiveLoopAndRearms(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "x", 5*time.Minute)
|
|
m.activeLoopID = m.loops[0].id
|
|
m.loops[0].nextRunAt = time.Time{} // as if the iteration is running
|
|
m.pending = true
|
|
m.activeRunID = 7
|
|
m.runCancel = func() {}
|
|
|
|
m.cancelRun()
|
|
|
|
if m.activeLoopID != "" {
|
|
t.Fatal("cancel should clear the active-loop tag so the next turn isn't misattributed")
|
|
}
|
|
if m.loops[0].nextRunAt.IsZero() {
|
|
t.Fatal("cancel should re-arm the interrupted loop, not leave it running forever")
|
|
}
|
|
if !m.loops[0].nextRunAt.Equal(now.Add(5 * time.Minute)) {
|
|
t.Fatalf("re-armed nextRunAt = %v, want +5m", m.loops[0].nextRunAt)
|
|
}
|
|
}
|
|
|
|
func TestValidateLoopTargetRejectsBuiltin(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
if _, ok := m.validateLoopTarget("/model list"); ok {
|
|
t.Error("looping a built-in command should be rejected")
|
|
}
|
|
if _, ok := m.validateLoopTarget("keep improving docs"); !ok {
|
|
t.Error("a plain prompt should be a valid loop target")
|
|
}
|
|
if _, ok := m.validateLoopTarget(""); ok {
|
|
t.Error("an empty target should be rejected")
|
|
}
|
|
// A custom command that does not exist is rejected up front, so it is never
|
|
// scheduled and then re-sent as literal "/…" text on every tick.
|
|
if _, ok := m.validateLoopTarget("/babysit-prs"); ok {
|
|
t.Error("an unresolved custom command should be rejected")
|
|
}
|
|
// A registered custom command is accepted.
|
|
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
|
if _, ok := m.validateLoopTarget("/babysit-prs"); !ok {
|
|
t.Error("an existing custom command should be a valid loop target")
|
|
}
|
|
}
|
|
|
|
func TestSelfPacedCommandLoopRejected(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m.userCommands = []usercommands.Command{{Name: "babysit-prs", Template: "check the PRs"}}
|
|
// A /command with no interval parses as self-paced, but self-paced mode drives
|
|
// a free-form goal via the playbook + LOOP: protocol, which a command's
|
|
// expanded template never carries — so a bare self-paced /command is rejected.
|
|
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: "/babysit-prs"})
|
|
if len(m.loops) != 0 {
|
|
t.Fatal("a bare self-paced /command loop should be rejected (a command loop needs an interval)")
|
|
}
|
|
// The same command with an interval (fixed) is accepted.
|
|
m, _ = m.startLoop(loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: "/babysit-prs", interval: 5 * time.Minute})
|
|
if len(m.loops) != 1 {
|
|
t.Fatal("a fixed-interval /command loop should be accepted")
|
|
}
|
|
}
|
|
|
|
func TestClearLoopsForSessionSwitch(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "a", time.Minute)
|
|
m = startFixedLoop(m, "b", time.Minute)
|
|
m.activeLoopID = m.loops[0].id
|
|
m, n := m.clearLoopsForSessionSwitch()
|
|
if n != 2 || len(m.loops) != 0 || m.activeLoopID != "" || m.loopTicking {
|
|
t.Fatalf("session switch should clear all loop state; got n=%d loops=%d active=%q ticking=%v",
|
|
n, len(m.loops), m.activeLoopID, m.loopTicking)
|
|
}
|
|
}
|
|
|
|
func TestStartNewSessionStopsLoops(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "x", time.Minute)
|
|
m = m.startNewSession()
|
|
if len(m.loops) != 0 || m.loopTicking {
|
|
t.Fatal("/new should stop loops tied to the previous session")
|
|
}
|
|
}
|
|
|
|
func TestLoopClearRequiresConfirm(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "x", time.Minute)
|
|
|
|
// First /clear with a loop active arms the confirm and does not clear yet.
|
|
m.input.SetValue("/clear")
|
|
u1, _ := m.Update(testKey(tea.KeyEnter))
|
|
n1 := u1.(model)
|
|
if n1.loopLeavePrompt != commandClear {
|
|
t.Fatal("first /clear with active loops should arm the confirm, not clear")
|
|
}
|
|
if len(n1.loops) != 1 {
|
|
t.Fatal("the loop should still be active after the warning")
|
|
}
|
|
|
|
// A different command disarms the confirm.
|
|
n1.input.SetValue("/loop list")
|
|
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
|
n2 := u2.(model)
|
|
if n2.loopLeavePrompt != commandEmpty {
|
|
t.Fatal("an intervening command should disarm the leave confirm")
|
|
}
|
|
|
|
// Re-arm, then a second consecutive /clear confirms (disarms + clears).
|
|
n2.input.SetValue("/clear")
|
|
u3, _ := n2.Update(testKey(tea.KeyEnter))
|
|
n3 := u3.(model)
|
|
n3.input.SetValue("/clear")
|
|
u4, _ := n3.Update(testKey(tea.KeyEnter))
|
|
n4 := u4.(model)
|
|
if n4.loopLeavePrompt != commandEmpty {
|
|
t.Fatal("a second consecutive /clear should confirm and disarm")
|
|
}
|
|
if len(n4.loops) != 1 {
|
|
t.Fatal("/clear keeps loops running (it wipes the screen, not the session)")
|
|
}
|
|
// The confirmed clear must actually wipe the transcript, not silently no-op:
|
|
// only the clear path appends the "Transcript cleared" note, and the pre-clear
|
|
// loop-start row must be gone.
|
|
clearedNote, staleRow := false, false
|
|
for _, r := range n4.transcript {
|
|
if strings.Contains(r.text, "Transcript cleared") {
|
|
clearedNote = true
|
|
}
|
|
if strings.Contains(r.text, "Loop L1 started") {
|
|
staleRow = true
|
|
}
|
|
}
|
|
if !clearedNote || staleRow {
|
|
t.Fatalf("the second /clear must wipe the transcript (clearedNote=%v staleRow=%v)", clearedNote, staleRow)
|
|
}
|
|
}
|
|
|
|
func TestLeaveConfirmDisarmedByQueuedPrompt(t *testing.T) {
|
|
now := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
m := loopTestModel(t, now)
|
|
m = startFixedLoop(m, "x", time.Minute)
|
|
|
|
// Arm the /clear confirm.
|
|
m.input.SetValue("/clear")
|
|
u1, _ := m.Update(testKey(tea.KeyEnter))
|
|
n1 := u1.(model)
|
|
if n1.loopLeavePrompt != commandClear {
|
|
t.Fatal("first /clear should arm the confirm")
|
|
}
|
|
|
|
// A prompt submitted while a run streams is queued via an early return in
|
|
// handleSubmit; it must still disarm the confirm (the disarm runs before those
|
|
// returns). Otherwise a later /clear would be treated as the second press.
|
|
n1.pending = true
|
|
n1.input.SetValue("a follow-up question")
|
|
u2, _ := n1.Update(testKey(tea.KeyEnter))
|
|
n2 := u2.(model)
|
|
if n2.loopLeavePrompt != commandEmpty {
|
|
t.Fatal("a queued prompt should disarm the leave confirm; the early return must not skip the disarm")
|
|
}
|
|
}
|