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
157 lines
5.8 KiB
Go
157 lines
5.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestParseLoopCommandModes(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
action loopAction
|
|
mode loopMode
|
|
prompt string
|
|
interval time.Duration
|
|
}{
|
|
{"", loopActionUsage, 0, "", 0},
|
|
{"list", loopActionList, 0, "", 0},
|
|
{"status", loopActionList, 0, "", 0},
|
|
{"stop all", loopActionStopAll, 0, "", 0},
|
|
{"stop ab12", loopActionStop, 0, "", 0},
|
|
{"5m /babysit-prs", loopActionStart, loopModeFixed, "/babysit-prs", 5 * time.Minute},
|
|
{"90s check the build", loopActionStart, loopModeFixed, "check the build", 90 * time.Second},
|
|
{"watch CI every 2m", loopActionStart, loopModeFixed, "watch CI", 2 * time.Minute},
|
|
{"keep improving the docs", loopActionStart, loopModeSelfPaced, "keep improving the docs", 0},
|
|
// "every PR" is not a cadence — the token after "every" isn't a duration.
|
|
{"review every PR", loopActionStart, loopModeSelfPaced, "review every PR", 0},
|
|
}
|
|
for _, c := range cases {
|
|
got := parseLoopCommand(c.in)
|
|
if got.action != c.action {
|
|
t.Errorf("%q action = %v, want %v", c.in, got.action, c.action)
|
|
}
|
|
if c.action == loopActionStart {
|
|
if got.mode != c.mode {
|
|
t.Errorf("%q mode = %v, want %v", c.in, got.mode, c.mode)
|
|
}
|
|
if got.prompt != c.prompt {
|
|
t.Errorf("%q prompt = %q, want %q", c.in, got.prompt, c.prompt)
|
|
}
|
|
if got.interval != c.interval {
|
|
t.Errorf("%q interval = %v, want %v", c.in, got.interval, c.interval)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseLoopCommandStopTarget(t *testing.T) {
|
|
if got := parseLoopCommand("stop"); got.action != loopActionStop || got.targetID != "" {
|
|
t.Fatalf("bare stop = %+v, want stop with empty target", got)
|
|
}
|
|
if got := parseLoopCommand("stop XyZ9"); got.targetID != "XyZ9" {
|
|
t.Fatalf("stop target = %q, want XyZ9", got.targetID)
|
|
}
|
|
}
|
|
|
|
func TestClampLoopInterval(t *testing.T) {
|
|
if d, note := clampLoopInterval(5 * time.Second); d != loopMinInterval || note == "" {
|
|
t.Fatalf("below-min = (%v,%q), want raised to min with note", d, note)
|
|
}
|
|
if d, note := clampLoopInterval(48 * time.Hour); d != loopMaxInterval || note == "" {
|
|
t.Fatalf("above-max = (%v,%q), want lowered to max with note", d, note)
|
|
}
|
|
if d, note := clampLoopInterval(5 * time.Minute); d != 5*time.Minute || note != "" {
|
|
t.Fatalf("in-range = (%v,%q), want unchanged, no note", d, note)
|
|
}
|
|
}
|
|
|
|
func TestClampSelfPaceDelay(t *testing.T) {
|
|
if got := clampSelfPaceDelay(0); got != loopSelfPaceDefault {
|
|
t.Errorf("zero delay = %v, want default %v", got, loopSelfPaceDefault)
|
|
}
|
|
if got := clampSelfPaceDelay(10 * time.Second); got != loopSelfPaceMin {
|
|
t.Errorf("below-min = %v, want %v", got, loopSelfPaceMin)
|
|
}
|
|
if got := clampSelfPaceDelay(3 * time.Hour); got != loopSelfPaceMax {
|
|
t.Errorf("above-max = %v, want %v", got, loopSelfPaceMax)
|
|
}
|
|
if got := clampSelfPaceDelay(10 * time.Minute); got != 10*time.Minute {
|
|
t.Errorf("in-band = %v, want unchanged", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatLoopDuration(t *testing.T) {
|
|
cases := map[time.Duration]string{
|
|
30 * time.Second: "30s",
|
|
90 * time.Second: "90s",
|
|
5 * time.Minute: "5m",
|
|
2 * time.Hour: "2h",
|
|
24 * time.Hour: "1d",
|
|
}
|
|
for d, want := range cases {
|
|
if got := formatLoopDuration(d); got != want {
|
|
t.Errorf("formatLoopDuration(%v) = %q, want %q", d, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseLoopControl(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
done bool
|
|
wake time.Duration
|
|
}{
|
|
{"did the work\nLOOP: DONE", true, 0},
|
|
{"progress\nLOOP: CONTINUE", false, 0},
|
|
{"progress\nLOOP: CONTINUE 10m", false, 10 * time.Minute},
|
|
{"progress\n- loop: continue 1h", false, time.Hour}, // markdown bullet, lowercase
|
|
{"LOOP: CONTINUE 5m\n...more...\nLOOP: DONE", true, 0}, // last control line wins
|
|
{"no control line here", false, 0}, // absent -> adaptive
|
|
{"LOOP: DONE — all tests pass", true, 0}, // a trailing note is fine
|
|
{"progress\nLOOP: CONTINUE 5m — still tidying", false, 5 * time.Minute}, // note after the interval
|
|
{"work done.\r\nLOOP: DONE\r\n", true, 0}, // CRLF endings still parse
|
|
{"work\r\nLOOP: CONTINUE 10m\r\n", false, 10 * time.Minute}, // CRLF + interval
|
|
{"I will emit LOOP: DONE when finished", false, 0}, // mid-sentence mention doesn't match
|
|
{"LOOP: CONTINUE 99z", false, 0}, // bad unit -> continue, no wake hint
|
|
}
|
|
for _, c := range cases {
|
|
done, wake := parseLoopControl(c.in)
|
|
if done != c.done || wake != c.wake {
|
|
t.Errorf("parseLoopControl(%q) = (%v,%v), want (%v,%v)", c.in, done, wake, c.done, c.wake)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFirePromptWrapsSelfPacedOnly(t *testing.T) {
|
|
self := &loopState{mode: loopModeSelfPaced, prompt: "tidy the docs"}
|
|
got := self.firePrompt()
|
|
if !strings.Contains(got, "LOOP: DONE") || !strings.Contains(got, "tidy the docs") {
|
|
t.Errorf("self-paced firePrompt should embed the playbook and goal, got %q", got)
|
|
}
|
|
fixed := &loopState{mode: loopModeFixed, prompt: "check the build"}
|
|
if fixed.firePrompt() != "check the build" {
|
|
t.Errorf("fixed-interval firePrompt should be verbatim, got %q", fixed.firePrompt())
|
|
}
|
|
// A self-paced /command is a real command, not a goal — send it verbatim, no playbook.
|
|
cmd := &loopState{mode: loopModeSelfPaced, prompt: "/babysit"}
|
|
if cmd.firePrompt() != "/babysit" {
|
|
t.Errorf("self-paced /command firePrompt should be verbatim, got %q", cmd.firePrompt())
|
|
}
|
|
}
|
|
|
|
func TestLoopStateDue(t *testing.T) {
|
|
base := time.Date(2026, 7, 5, 12, 0, 0, 0, time.UTC)
|
|
l := &loopState{nextRunAt: base}
|
|
if l.due(base.Add(-time.Second)) {
|
|
t.Error("loop should not be due before nextRunAt")
|
|
}
|
|
if !l.due(base) {
|
|
t.Error("loop should be due at nextRunAt")
|
|
}
|
|
l.paused = true
|
|
if l.due(base.Add(time.Hour)) {
|
|
t.Error("paused loop should never be due")
|
|
}
|
|
}
|