mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330b10fe8e | ||
|
|
ac25352e12 | ||
|
|
de7f0b6a5b | ||
|
|
3eafaf584d | ||
|
|
9cfdb2fecf | ||
|
|
4ffbf4e0fe | ||
|
|
440d4df367 | ||
|
|
4bc9a97176 | ||
|
|
6d7d3946c8 |
No files matched your search
@@ -186,6 +186,7 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
|
||||
@@ -47,6 +47,7 @@ const (
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandLoop
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -297,6 +298,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Write the conversation transcript to a file.",
|
||||
kind: commandExport,
|
||||
},
|
||||
{
|
||||
name: "/loop",
|
||||
usage: "/loop [interval] <prompt|/command> | /loop list | /loop stop [id|all]",
|
||||
group: commandGroupSession,
|
||||
description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.",
|
||||
kind: commandLoop,
|
||||
},
|
||||
{
|
||||
name: "/help",
|
||||
usage: "/help",
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// The /loop command repeats a prompt or slash command in one of two modes:
|
||||
//
|
||||
// - Fixed interval — `/loop 5m <prompt>` re-fires on a wall-clock cadence.
|
||||
// - Self-paced — bare `/loop <prompt>` runs a maintenance playbook: the model
|
||||
// does one increment of work each iteration and ends its reply with a control
|
||||
// line (`LOOP: DONE` / `LOOP: CONTINUE [interval]`) that stops the loop or picks
|
||||
// the next wake. With no control line it falls back to an adaptive cadence.
|
||||
//
|
||||
// Loops are foreground and session-scoped: a tick only fires while the session is
|
||||
// idle between turns (it never interrupts a streaming turn), the loop lives with the
|
||||
// session that created it, and switching sessions (/new, /resume) stops it. Safety
|
||||
// rails cap iterations, age, consecutive failures, and no-progress repetition.
|
||||
// Everything here is pure/loop-local; the turn-lifecycle wiring (ticker, launch,
|
||||
// continuation) lives in model.go.
|
||||
|
||||
type loopMode int
|
||||
|
||||
const (
|
||||
loopModeFixed loopMode = iota // re-fire every fixed interval
|
||||
loopModeSelfPaced // model chooses the next wake each iteration
|
||||
)
|
||||
|
||||
// Safety bounds. A loop is foreground + session-scoped, but these keep a runaway
|
||||
// or forgotten loop from burning tokens forever.
|
||||
const (
|
||||
loopMinInterval = 30 * time.Second
|
||||
loopMaxInterval = 24 * time.Hour
|
||||
loopMaxIterations = 500 // hard ceiling; the loop auto-stops after this many
|
||||
|
||||
// loopMaxAge auto-expires a loop this long after it was created, so a forgotten
|
||||
// loop stops on its own even if it never reaches the iteration cap.
|
||||
loopMaxAge = 24 * time.Hour
|
||||
|
||||
loopSelfPaceMin = 1 * time.Minute
|
||||
loopSelfPaceMax = 1 * time.Hour
|
||||
loopSelfPaceDefault = 15 * time.Minute
|
||||
|
||||
// loopDoomThreshold ends a loop after this many consecutive identical final
|
||||
// answers — a self-paced or interval loop that gets stuck repeating the same
|
||||
// no-progress result should stop rather than churn.
|
||||
loopDoomThreshold = 5
|
||||
|
||||
// loopMaxFailures ends a loop after this many consecutive failed iterations, so
|
||||
// a loop whose iterations keep erroring stops instead of churning to the
|
||||
// iteration cap (a failed turn has no final answer, so the doom guard can't see
|
||||
// the lack of progress).
|
||||
loopMaxFailures = 3
|
||||
|
||||
// loopPollInterval is how often the controller checks whether a loop is due.
|
||||
// A loop only fires while idle, so this is a cheap between-turn poll.
|
||||
loopPollInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
// loopState is one active loop owned by the current session.
|
||||
type loopState struct {
|
||||
id string
|
||||
mode loopMode
|
||||
prompt string // the prompt or "/command" re-run each iteration
|
||||
interval time.Duration
|
||||
iteration int
|
||||
maxIter int // 0 => loopMaxIterations
|
||||
createdAt time.Time
|
||||
nextRunAt time.Time
|
||||
// lastResult / repeatRun drive doom-loop detection: consecutive identical
|
||||
// final answers past loopDoomThreshold end the loop.
|
||||
lastResult string
|
||||
repeatRun int
|
||||
// failRun counts consecutive failed iterations; past loopMaxFailures the loop
|
||||
// stops (a failed turn has no final answer for the doom guard to compare).
|
||||
failRun int
|
||||
paused bool
|
||||
}
|
||||
|
||||
func (l *loopState) iterationCap() int {
|
||||
if l.maxIter > 0 {
|
||||
return l.maxIter
|
||||
}
|
||||
return loopMaxIterations
|
||||
}
|
||||
|
||||
// due reports whether the loop should fire at now (active, not paused, past its
|
||||
// next-run mark).
|
||||
func (l *loopState) due(now time.Time) bool {
|
||||
return !l.paused && !l.nextRunAt.IsZero() && !now.Before(l.nextRunAt)
|
||||
}
|
||||
|
||||
// cadenceText renders the loop's cadence for status lines.
|
||||
func (l *loopState) cadenceText() string {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
return "self-paced"
|
||||
}
|
||||
return "every " + formatLoopDuration(l.interval)
|
||||
}
|
||||
|
||||
type loopAction int
|
||||
|
||||
const (
|
||||
loopActionUsage loopAction = iota // show usage / list when empty
|
||||
loopActionStart // start a new loop
|
||||
loopActionStop // stop one by id
|
||||
loopActionStopAll // stop every loop
|
||||
loopActionList // list active loops
|
||||
)
|
||||
|
||||
// loopCommand is the parsed form of a `/loop ...` invocation (the text after the
|
||||
// command name).
|
||||
type loopCommand struct {
|
||||
action loopAction
|
||||
targetID string
|
||||
mode loopMode
|
||||
prompt string
|
||||
interval time.Duration
|
||||
// note carries a non-fatal advisory (e.g. an interval that was clamped) to
|
||||
// surface back to the user.
|
||||
note string
|
||||
}
|
||||
|
||||
var loopIntervalRe = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
// loopControlRe matches a self-paced loop's control line at the start of a line,
|
||||
// tolerant of a leading markdown bullet/quote marker and any trailing note after
|
||||
// the keyword: "LOOP: DONE", "- loop: continue", "LOOP: CONTINUE 10m — still tidying".
|
||||
// The leading ^ anchor keeps a mid-sentence mention ("I'll emit LOOP: DONE when…")
|
||||
// from matching; parseLoopControl normalizes CRLF first so `$` can reach line end.
|
||||
var loopControlRe = regexp.MustCompile(`(?im)^[^\S\r\n]*(?:[-*>•][^\S\r\n]*)?loop:[^\S\r\n]*(done|continue)\b(?:[^\S\r\n]+(\d+[smhd])\b)?.*$`)
|
||||
|
||||
// parseLoopCommand interprets the arguments to `/loop`. Forms:
|
||||
//
|
||||
// (empty) -> usage/list
|
||||
// list | status -> list active loops
|
||||
// stop | stop all | stop <id>
|
||||
// <interval> <prompt> -> fixed-interval loop (e.g. "5m /babysit")
|
||||
// <prompt> every <interval>
|
||||
// <prompt> -> self-paced loop (no interval)
|
||||
func parseLoopCommand(args string) loopCommand {
|
||||
trimmed := strings.TrimSpace(args)
|
||||
if trimmed == "" {
|
||||
return loopCommand{action: loopActionUsage}
|
||||
}
|
||||
|
||||
first := strings.ToLower(strings.Fields(trimmed)[0])
|
||||
switch first {
|
||||
case "list", "status", "ls":
|
||||
return loopCommand{action: loopActionList}
|
||||
case "stop", "cancel", "kill":
|
||||
rest := strings.TrimSpace(trimmed[len(strings.Fields(trimmed)[0]):])
|
||||
if rest == "" || strings.EqualFold(rest, "all") {
|
||||
if strings.EqualFold(rest, "all") {
|
||||
return loopCommand{action: loopActionStopAll}
|
||||
}
|
||||
// bare "stop" stops all when ambiguous; the caller may special-case a
|
||||
// single active loop.
|
||||
return loopCommand{action: loopActionStop, targetID: ""}
|
||||
}
|
||||
return loopCommand{action: loopActionStop, targetID: strings.TrimSpace(rest)}
|
||||
}
|
||||
|
||||
// Leading interval token: "5m <prompt>".
|
||||
if fields := strings.Fields(trimmed); len(fields) >= 2 {
|
||||
if d, ok := parseLoopInterval(fields[0]); ok {
|
||||
prompt := strings.TrimSpace(trimmed[len(fields[0]):])
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing "every <interval>".
|
||||
if prompt, d, ok := splitTrailingEvery(trimmed); ok {
|
||||
cmd := loopCommand{action: loopActionStart, mode: loopModeFixed, prompt: prompt}
|
||||
cmd.interval, cmd.note = clampLoopInterval(d)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// No interval anywhere -> self-paced.
|
||||
return loopCommand{action: loopActionStart, mode: loopModeSelfPaced, prompt: trimmed}
|
||||
}
|
||||
|
||||
// splitTrailingEvery matches "<prompt> every <interval>" (the interval must be a
|
||||
// real duration token so "check every PR" is not mis-parsed as a cadence).
|
||||
func splitTrailingEvery(input string) (string, time.Duration, bool) {
|
||||
fields := strings.Fields(input)
|
||||
if len(fields) < 3 {
|
||||
return "", 0, false
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
prev := fields[len(fields)-2]
|
||||
if !strings.EqualFold(prev, "every") {
|
||||
return "", 0, false
|
||||
}
|
||||
d, ok := parseLoopInterval(last)
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
prompt := strings.TrimSpace(strings.Join(fields[:len(fields)-2], " "))
|
||||
if prompt == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return prompt, d, true
|
||||
}
|
||||
|
||||
// parseLoopInterval parses a bare "<N><unit>" token (s/m/h/d) into a duration.
|
||||
func parseLoopInterval(token string) (time.Duration, bool) {
|
||||
m := loopIntervalRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(token)))
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
unit := map[string]time.Duration{
|
||||
"s": time.Second,
|
||||
"m": time.Minute,
|
||||
"h": time.Hour,
|
||||
"d": 24 * time.Hour,
|
||||
}[m[2]]
|
||||
return time.Duration(n) * unit, true
|
||||
}
|
||||
|
||||
// clampLoopInterval bounds an interval to [loopMinInterval, loopMaxInterval] and
|
||||
// returns an advisory note when it was adjusted.
|
||||
func clampLoopInterval(d time.Duration) (time.Duration, string) {
|
||||
switch {
|
||||
case d < loopMinInterval:
|
||||
return loopMinInterval, fmt.Sprintf("interval raised to the %s minimum", formatLoopDuration(loopMinInterval))
|
||||
case d > loopMaxInterval:
|
||||
return loopMaxInterval, fmt.Sprintf("interval lowered to the %s maximum", formatLoopDuration(loopMaxInterval))
|
||||
default:
|
||||
return d, ""
|
||||
}
|
||||
}
|
||||
|
||||
// loopTickMsg is the between-turn poll that fires a due loop while the session is
|
||||
// idle. seq invalidates a tick left over from before the loop set changed.
|
||||
type loopTickMsg struct{ seq int }
|
||||
|
||||
// handleLoopCommand dispatches a parsed `/loop ...` invocation.
|
||||
func (m model) handleLoopCommand(args string) (model, tea.Cmd) {
|
||||
cmd := parseLoopCommand(args)
|
||||
switch cmd.action {
|
||||
case loopActionList:
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
case loopActionUsage:
|
||||
if len(m.loops) > 0 {
|
||||
return m.appendLoopSystem(m.loopListText()), nil
|
||||
}
|
||||
return m.appendLoopSystem(loopUsageText()), nil
|
||||
case loopActionStop:
|
||||
return m.stopLoop(cmd.targetID)
|
||||
case loopActionStopAll:
|
||||
return m.stopAllLoops()
|
||||
case loopActionStart:
|
||||
return m.startLoop(cmd)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// startLoop registers a new loop and (re)starts the idle poll ticker. The first
|
||||
// iteration fires on the next idle tick (nextRunAt = now).
|
||||
func (m model) startLoop(cmd loopCommand) (model, tea.Cmd) {
|
||||
if reason, ok := m.validateLoopTarget(cmd.prompt); !ok {
|
||||
return m.appendLoopSystem(reason), nil
|
||||
}
|
||||
// A /command loop must be fixed-interval. Self-paced mode drives a free-form
|
||||
// goal through the playbook + LOOP: control line (see firePrompt), which a
|
||||
// custom command's expanded template never carries — so a bare self-paced
|
||||
// /command would silently degrade to adaptive polling with no way to report
|
||||
// done. Require an explicit interval for command loops instead.
|
||||
if cmd.mode == loopModeSelfPaced && strings.HasPrefix(strings.TrimSpace(cmd.prompt), "/") {
|
||||
return m.appendLoopSystem("A /command loop needs an interval — e.g. /loop 5m " + strings.Fields(cmd.prompt)[0] + ". Self-paced mode (no interval) is for a free-form goal."), nil
|
||||
}
|
||||
m.loopCounter++
|
||||
id := "L" + strconv.Itoa(m.loopCounter)
|
||||
loop := &loopState{
|
||||
id: id,
|
||||
mode: cmd.mode,
|
||||
prompt: cmd.prompt,
|
||||
interval: cmd.interval,
|
||||
createdAt: m.now(),
|
||||
nextRunAt: m.now(), // fire the first iteration on the next idle tick
|
||||
}
|
||||
m.loops = append(m.loops, loop)
|
||||
note := ""
|
||||
if cmd.note != "" {
|
||||
note = " (" + cmd.note + ")"
|
||||
}
|
||||
m = m.appendLoopSystem(fmt.Sprintf(
|
||||
"Loop %s started — %s%s. Runs while this session is idle; stop with /loop stop %s.",
|
||||
id, loop.cadenceText(), note, id))
|
||||
return m.ensureLoopTick()
|
||||
}
|
||||
|
||||
// stopLoop stops one loop by id, or the single active loop when the id is omitted.
|
||||
func (m model) stopLoop(id string) (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
if id == "" {
|
||||
if len(m.loops) == 1 {
|
||||
return m.removeLoop(m.loops[0].id, "Loop "+m.loops[0].id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
return m.appendLoopSystem("Multiple loops active — use /loop stop <id> or /loop stop all.\n" + m.loopListText()), nil
|
||||
}
|
||||
if m.findLoop(id) == nil {
|
||||
return m.appendLoopSystem("No loop " + id + ". " + m.loopListText()), nil
|
||||
}
|
||||
// removeLoop stops the poll ticker; re-arm it so any OTHER loops that remain
|
||||
// keep firing (ensureLoopTick no-ops when this was the last loop).
|
||||
return m.removeLoop(id, "Loop "+id+" stopped.").ensureLoopTick()
|
||||
}
|
||||
|
||||
func (m model) stopAllLoops() (model, tea.Cmd) {
|
||||
if len(m.loops) == 0 {
|
||||
return m.appendLoopSystem("No active loops."), nil
|
||||
}
|
||||
n := len(m.loops)
|
||||
m.loops = nil
|
||||
m.loopSeq++ // invalidate the pending poll tick
|
||||
m.loopTicking = false
|
||||
return m.appendLoopSystem(fmt.Sprintf("Stopped all %d loop(s).", n)), nil
|
||||
}
|
||||
|
||||
// fireDueLoopIfIdle fires the earliest due loop when the session is idle. Called
|
||||
// from the poll tick; a no-op while a turn, modal, or queued user message is
|
||||
// pending (the loop simply waits for the next idle tick).
|
||||
func (m model) fireDueLoopIfIdle() (model, tea.Cmd) {
|
||||
if m.loopBusy() || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
now := m.now()
|
||||
var due *loopState
|
||||
for _, l := range m.loops {
|
||||
if l.due(now) && (due == nil || l.nextRunAt.Before(due.nextRunAt)) {
|
||||
due = l
|
||||
}
|
||||
}
|
||||
if due == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.activeLoopID = due.id
|
||||
due.nextRunAt = time.Time{} // mark running
|
||||
return m.fireLoopPrompt(due.firePrompt())
|
||||
}
|
||||
|
||||
// firePrompt is the text actually sent for this iteration. A self-paced plain
|
||||
// prompt is wrapped in the maintenance playbook so the model knows to do one
|
||||
// increment of work and end with a LOOP: control line; a /command or a
|
||||
// fixed-interval prompt is sent verbatim.
|
||||
func (l *loopState) firePrompt() string {
|
||||
if l.mode == loopModeSelfPaced && !strings.HasPrefix(strings.TrimSpace(l.prompt), "/") {
|
||||
return loopSelfPacePlaybook(l)
|
||||
}
|
||||
return l.prompt
|
||||
}
|
||||
|
||||
// fireLoopPrompt runs a loop's prompt as a turn — a custom /command if it resolves
|
||||
// to one, otherwise a plain prompt.
|
||||
func (m model) fireLoopPrompt(prompt string) (model, tea.Cmd) {
|
||||
if strings.HasPrefix(strings.TrimSpace(prompt), "/") {
|
||||
if next, teaCmd, ok := m.handleUserCommand(prompt); ok {
|
||||
return next, teaCmd
|
||||
}
|
||||
}
|
||||
return m.launchPrompt(prompt)
|
||||
}
|
||||
|
||||
// advanceLoop updates a loop after one of its iterations completes: it folds the
|
||||
// result into the failure/no-progress guards, honors a self-paced DONE/next-wake
|
||||
// control line, enforces the iteration and age caps, then schedules the next wake
|
||||
// (or stops the loop).
|
||||
func (m model) advanceLoop(id, finalAnswer string, runErr error) model {
|
||||
l := m.findLoop(id)
|
||||
if l == nil {
|
||||
return m // stopped mid-run
|
||||
}
|
||||
l.iteration++
|
||||
if runErr != nil {
|
||||
l.failRun++
|
||||
} else {
|
||||
l.failRun = 0
|
||||
}
|
||||
res := strings.TrimSpace(finalAnswer)
|
||||
if res != "" && res == l.lastResult {
|
||||
l.repeatRun++
|
||||
} else {
|
||||
l.repeatRun = 0
|
||||
}
|
||||
l.lastResult = res
|
||||
|
||||
// Only self-paced iterations run the playbook, so only they can carry a control
|
||||
// line; a failed turn has no trustworthy final answer to read one from.
|
||||
done, wake := false, time.Duration(0)
|
||||
if l.mode == loopModeSelfPaced && runErr == nil {
|
||||
done, wake = parseLoopControl(finalAnswer)
|
||||
}
|
||||
|
||||
switch {
|
||||
case done:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s finished — the task reported done after %d iteration(s).", id, l.iteration))
|
||||
case l.failRun >= loopMaxFailures:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped after %d consecutive failed iterations.", id, l.failRun))
|
||||
case l.iteration >= l.iterationCap():
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped at its %d-iteration cap.", id, l.iteration))
|
||||
case !l.createdAt.IsZero() && m.now().Sub(l.createdAt) >= loopMaxAge:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s expired after %s.", id, formatLoopDuration(loopMaxAge)))
|
||||
case l.repeatRun >= loopDoomThreshold:
|
||||
return m.removeLoop(id, fmt.Sprintf("Loop %s stopped — %d identical results in a row (no progress).", id, l.repeatRun))
|
||||
}
|
||||
|
||||
if l.mode == loopModeSelfPaced {
|
||||
// A model-chosen next wake (from the control line) wins; otherwise fall back to
|
||||
// an adaptive cadence that checks back often while work is fresh and settles to
|
||||
// a slow heartbeat as iterations accrue.
|
||||
delay := wake
|
||||
if delay <= 0 {
|
||||
delay = adaptiveSelfPaceDelay(l.iteration)
|
||||
}
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(delay))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseLoopControl reads a self-paced loop's control signal from the model's final
|
||||
// answer. The last control line wins (the model may reference the protocol before
|
||||
// emitting it). Returns done=true to stop the loop, or a next-wake hint (0 = none).
|
||||
func parseLoopControl(answer string) (done bool, wake time.Duration) {
|
||||
// Normalize CRLF/CR so the line-end anchor can reach `$` (Go's (?m)$ sits right
|
||||
// before `\n`, and the horizontal-whitespace class can't consume a stray `\r`).
|
||||
norm := strings.ReplaceAll(answer, "\r\n", "\n")
|
||||
norm = strings.ReplaceAll(norm, "\r", "\n")
|
||||
matches := loopControlRe.FindAllStringSubmatch(norm, -1)
|
||||
if len(matches) == 0 {
|
||||
return false, 0
|
||||
}
|
||||
last := matches[len(matches)-1]
|
||||
if strings.EqualFold(last[1], "done") {
|
||||
return true, 0
|
||||
}
|
||||
if last[2] != "" {
|
||||
if d, ok := parseLoopInterval(last[2]); ok {
|
||||
return false, d
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// loopSelfPacePlaybook wraps a self-paced loop's goal in the maintenance-loop
|
||||
// instructions the model follows each iteration: do one real increment of work,
|
||||
// then emit a single control line so the harness knows whether to stop or when to
|
||||
// wake next — the self-paced maintenance playbook the feature ships with.
|
||||
func loopSelfPacePlaybook(l *loopState) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running in a self-paced maintenance loop (iteration ")
|
||||
b.WriteString(strconv.Itoa(l.iteration + 1))
|
||||
b.WriteString("). Do one concrete increment of work toward the goal below — actual work, not just a plan — then stop and report.\n\n")
|
||||
b.WriteString("Goal: ")
|
||||
b.WriteString(strings.TrimSpace(l.prompt))
|
||||
b.WriteString("\n\nEnd your reply with a control line — on its own line, starting with \"LOOP:\":\n")
|
||||
b.WriteString(" LOOP: DONE — the goal is fully met; the loop stops.\n")
|
||||
b.WriteString(" LOOP: CONTINUE — more to do; the loop wakes again automatically.\n")
|
||||
b.WriteString(" LOOP: CONTINUE 10m — more to do; wake after the delay you name (e.g. 5m, 1h).\n")
|
||||
b.WriteString("A short note after the keyword is fine. Report DONE only when the goal is genuinely complete — the loop trusts that signal to stop.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// adaptiveSelfPaceDelay picks a self-paced loop's next-wake by iteration: frequent
|
||||
// early (work is actively progressing), slowing to a heartbeat as it matures.
|
||||
func adaptiveSelfPaceDelay(iteration int) time.Duration {
|
||||
switch {
|
||||
case iteration < 2:
|
||||
return 2 * time.Minute
|
||||
case iteration < 5:
|
||||
return 5 * time.Minute
|
||||
case iteration < 10:
|
||||
return 15 * time.Minute
|
||||
default:
|
||||
return 30 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) findLoop(id string) *loopState {
|
||||
for _, l := range m.loops {
|
||||
if l.id == id {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) removeLoop(id, note string) model {
|
||||
kept := make([]*loopState, 0, len(m.loops))
|
||||
for _, l := range m.loops {
|
||||
if l.id != id {
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
m.loops = kept
|
||||
m.loopSeq++ // invalidate the pending poll tick; a fresh one is scheduled if loops remain
|
||||
m.loopTicking = false
|
||||
if note != "" {
|
||||
m = m.appendLoopSystem(note)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ensureLoopTick starts the idle poll ticker if it is not already running.
|
||||
func (m model) ensureLoopTick() (model, tea.Cmd) {
|
||||
if m.loopTicking || len(m.loops) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.loopTicking = true
|
||||
return m, m.scheduleLoopTick()
|
||||
}
|
||||
|
||||
func (m model) scheduleLoopTick() tea.Cmd {
|
||||
seq := m.loopSeq
|
||||
return tea.Tick(loopPollInterval, func(time.Time) tea.Msg {
|
||||
return loopTickMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
// loopBusy reports whether the session is too busy for a loop to fire — a turn,
|
||||
// queued user message, compaction, or ANY blocking modal (permission/askUser/spec
|
||||
// review, provider or MCP wizard, MCP manager, or an open picker) is in flight.
|
||||
// The modal check is load-bearing: a loop must not launch a run behind an open
|
||||
// /resume picker, or that run would complete into whatever session the user then
|
||||
// switches to. Mirrors launchQueuedMessageIfReady so loops and queued prompts never
|
||||
// collide.
|
||||
func (m model) loopBusy() bool {
|
||||
return m.pending || m.exiting || m.compactInFlight || m.hasQueuedMessage() || !m.noBlockingModal()
|
||||
}
|
||||
|
||||
func (m model) appendLoopSystem(text string) model {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m
|
||||
}
|
||||
|
||||
// loopActive reports whether any loop is running (for the footer + leave warning).
|
||||
func (m model) loopActive() bool { return len(m.loops) > 0 }
|
||||
|
||||
// loopFooterSummary renders the persistent "N loops · next 3:05pm" footer segment,
|
||||
// or "" when no loops are active.
|
||||
func (m model) loopFooterSummary() string {
|
||||
if len(m.loops) == 0 {
|
||||
return ""
|
||||
}
|
||||
var next time.Time
|
||||
for _, l := range m.loops {
|
||||
if l.nextRunAt.IsZero() || l.paused {
|
||||
continue
|
||||
}
|
||||
if next.IsZero() || l.nextRunAt.Before(next) {
|
||||
next = l.nextRunAt
|
||||
}
|
||||
}
|
||||
label := fmt.Sprintf("%d loop", len(m.loops))
|
||||
if len(m.loops) != 1 {
|
||||
label += "s"
|
||||
}
|
||||
if next.IsZero() {
|
||||
return label
|
||||
}
|
||||
return label + " · next " + next.Format("3:04pm")
|
||||
}
|
||||
|
||||
func (m model) loopListText() string {
|
||||
if len(m.loops) == 0 {
|
||||
return "No active loops."
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Active loops:\n")
|
||||
now := m.now()
|
||||
for _, l := range m.loops {
|
||||
when := "running"
|
||||
if !l.nextRunAt.IsZero() {
|
||||
if d := l.nextRunAt.Sub(now); d > 0 {
|
||||
when = "next in " + formatLoopDuration(d.Round(time.Second))
|
||||
} else {
|
||||
when = "due"
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s · %s · iter %d · %s · %s\n",
|
||||
l.id, l.cadenceText(), l.iteration, when, truncateLoopPrompt(l.prompt)))
|
||||
}
|
||||
b.WriteString("Stop with /loop stop <id> or /loop stop all.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func loopUsageText() string {
|
||||
return "Repeat a prompt or command:\n" +
|
||||
" /loop 5m /babysit-prs — run a command every 5 minutes\n" +
|
||||
" /loop watch CI every 2m — trailing interval form\n" +
|
||||
" /loop keep tidying docs — self-paced (the model picks its own cadence and stops when done)\n" +
|
||||
" /loop list — show active loops\n" +
|
||||
" /loop stop [id|all] — stop a loop\n" +
|
||||
"Loops run while this session is idle and stop when you close it."
|
||||
}
|
||||
|
||||
// validateLoopTarget rejects looping a built-in command or an unresolved custom
|
||||
// command — only a prompt or an existing custom /command (.zero/commands/<name>.md)
|
||||
// may loop. Resolving custom commands up front stops a typo'd `/loop 5m /nope` from
|
||||
// being scheduled and then re-sent as literal text on every tick.
|
||||
func (m model) validateLoopTarget(prompt string) (string, bool) {
|
||||
p := strings.TrimSpace(prompt)
|
||||
if p == "" {
|
||||
return "Nothing to loop — give a prompt or a /command.", false
|
||||
}
|
||||
if strings.HasPrefix(p, "/") {
|
||||
switch parseCommand(p).kind {
|
||||
case commandPrompt:
|
||||
// A bare "/" or similar that isn't a command — treat as a plain prompt.
|
||||
case commandUnknown:
|
||||
name, _ := splitUserCommand(p)
|
||||
if _, ok := m.lookupUserCommand(name); !ok {
|
||||
return "No command /" + name + " — a loop can only run a prompt or an existing custom /command.", false
|
||||
}
|
||||
default:
|
||||
return "A loop can only run a prompt or a custom /command, not a built-in like " + strings.Fields(p)[0] + ".", false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// clearLoopsForSessionSwitch drops every active loop and its ticker. Loops belong to
|
||||
// the session that created them; carrying them across /new or /resume would fire the
|
||||
// old session's prompt into an unrelated conversation. Returns the count cleared so
|
||||
// the caller can note it. Pure state reset — no transcript writes.
|
||||
func (m model) clearLoopsForSessionSwitch() (model, int) {
|
||||
n := len(m.loops)
|
||||
if n == 0 {
|
||||
return m, 0
|
||||
}
|
||||
m.loops = nil
|
||||
m.activeLoopID = ""
|
||||
m.loopTicking = false
|
||||
m.loopSeq++ // invalidate any pending poll tick
|
||||
return m, n
|
||||
}
|
||||
|
||||
func truncateLoopPrompt(prompt string) string {
|
||||
const max = 48
|
||||
p := strings.TrimSpace(strings.ReplaceAll(prompt, "\n", " "))
|
||||
runes := []rune(p)
|
||||
if len(runes) <= max {
|
||||
return p
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:max])) + "…"
|
||||
}
|
||||
|
||||
// clampSelfPaceDelay bounds a model-chosen self-paced delay to the self-pace
|
||||
// band, defaulting an unset/invalid value.
|
||||
func clampSelfPaceDelay(d time.Duration) time.Duration {
|
||||
switch {
|
||||
case d <= 0:
|
||||
return loopSelfPaceDefault
|
||||
case d < loopSelfPaceMin:
|
||||
return loopSelfPaceMin
|
||||
case d > loopSelfPaceMax:
|
||||
return loopSelfPaceMax
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
// formatLoopDuration renders a duration compactly (90s, 5m, 2h, 1d) for status
|
||||
// text, preferring the largest whole unit.
|
||||
func formatLoopDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d%(24*time.Hour) == 0 && d >= 24*time.Hour:
|
||||
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||
case d%time.Hour == 0 && d >= time.Hour:
|
||||
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||
case d%time.Minute == 0 && d >= time.Minute:
|
||||
return strconv.Itoa(int(d/time.Minute)) + "m"
|
||||
default:
|
||||
return strconv.Itoa(int(d/time.Second)) + "s"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+89
-5
@@ -190,10 +190,22 @@ type model struct {
|
||||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||||
turnStartedAt time.Time
|
||||
queuedMessage string
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
|
||||
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
|
||||
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
|
||||
// pending poll tick when loops are stopped; loopTicking guards against scheduling
|
||||
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
|
||||
// /quit while loops are active (see handleSubmit).
|
||||
loops []*loopState
|
||||
activeLoopID string
|
||||
loopSeq int
|
||||
loopCounter int
|
||||
loopTicking bool
|
||||
loopLeavePrompt commandKind
|
||||
exiting bool
|
||||
runCancel context.CancelFunc
|
||||
runID int
|
||||
activeRunID int
|
||||
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
|
||||
// to the session they were recording into AT CANCEL TIME. Each cancelled
|
||||
// agent goroutine keeps running to completion and returns its accumulated
|
||||
@@ -1896,6 +1908,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
case loopTickMsg:
|
||||
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
|
||||
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
|
||||
if msg.seq != m.loopSeq || len(m.loops) == 0 {
|
||||
m.loopTicking = false
|
||||
return m, nil
|
||||
}
|
||||
var fireCmd tea.Cmd
|
||||
m, fireCmd = m.fireDueLoopIfIdle()
|
||||
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
|
||||
case agentResponseMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
// A run cancelled while in flight still finishes in its goroutine and
|
||||
@@ -2065,8 +2087,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// complete once the turn settles.
|
||||
var sweepCmd tea.Cmd
|
||||
m, sweepCmd = m.maybeGitSweep()
|
||||
// If this run was a loop iteration, advance that loop (schedule its next wake
|
||||
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
|
||||
// still wins the immediate re-launch; the loop fires on the next idle tick.
|
||||
var loopTickCmd tea.Cmd
|
||||
if loopID := m.activeLoopID; loopID != "" {
|
||||
m.activeLoopID = ""
|
||||
loopFinalAnswer := ""
|
||||
for _, row := range msg.rows {
|
||||
if row.kind == rowAssistant && row.final {
|
||||
loopFinalAnswer = row.text
|
||||
}
|
||||
}
|
||||
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
|
||||
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
|
||||
// other loops remain.
|
||||
m, loopTickCmd = m.ensureLoopTick()
|
||||
}
|
||||
next, queuedCmd := m.launchQueuedMessageIfReady()
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapGeneratedMsg:
|
||||
@@ -3789,6 +3828,13 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
command := parseCommand(input)
|
||||
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
|
||||
// repeat of that same command only; any other submission — including one queued
|
||||
// or deferred by the early returns below — disarms it. Runs before those returns
|
||||
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
|
||||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
}
|
||||
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
|
||||
// new run must not start: the deferred tea.Quit would abort it mid-flight
|
||||
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
|
||||
@@ -3822,6 +3868,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
|
||||
return m, nil
|
||||
case commandClear:
|
||||
// A foreground loop keeps firing after /clear (it wipes the screen, not the
|
||||
// session), so warn once before clearing the context it will run into.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandClear {
|
||||
m.loopLeavePrompt = commandClear
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
@@ -3830,6 +3884,11 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
// /clear wipes the transcript but not the session, so loops keep running;
|
||||
// say so rather than let them silently fire into a "cleared" screen.
|
||||
if m.loopActive() {
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
|
||||
}
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
@@ -3840,7 +3899,17 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandLoop:
|
||||
return m.handleLoopCommand(command.text)
|
||||
case commandExit:
|
||||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||||
// token-spending loop isn't ended by reflex.
|
||||
if m.loopActive() && m.loopLeavePrompt != commandExit {
|
||||
m.loopLeavePrompt = commandExit
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
|
||||
return m, nil
|
||||
}
|
||||
m.loopLeavePrompt = commandEmpty
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
// immediately would orphan the blobs and break /rewind.
|
||||
@@ -4333,6 +4402,21 @@ func (m *model) cancelRun() {
|
||||
m.runCancel()
|
||||
}
|
||||
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
|
||||
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
|
||||
// late message is drained through flushRunIDs, not advanceLoop), so clear the
|
||||
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
|
||||
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
|
||||
// unrelated turn would be misattributed as this loop's completion.
|
||||
if m.activeLoopID != "" {
|
||||
if l := m.findLoop(m.activeLoopID); l != nil {
|
||||
if l.mode == loopModeSelfPaced {
|
||||
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
|
||||
} else {
|
||||
l.nextRunAt = m.now().Add(l.interval)
|
||||
}
|
||||
}
|
||||
m.activeLoopID = ""
|
||||
}
|
||||
// Remember the in-flight run — and the session it was recording into — so
|
||||
// its final agentResponseMsg is still drained for session-event persistence
|
||||
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
|
||||
|
||||
@@ -99,6 +99,12 @@ func (m model) startNewSession() model {
|
||||
// Scrollback above can't be un-printed; a faint divider marks the boundary and
|
||||
// the flush frontier restarts for the fresh transcript (mirrors /clear, /resume).
|
||||
m.resetFlushFrontier("· new session ·")
|
||||
// Loops belong to the previous session; stop them so they don't fire the old
|
||||
// conversation's prompt into the fresh one.
|
||||
if updated, cleared := m.clearLoopsForSessionSwitch(); cleared > 0 {
|
||||
m = updated
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", cleared)})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -205,6 +211,10 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
return m, "Sessions\nerror: " + err.Error()
|
||||
}
|
||||
|
||||
// Capture the current session id before switching so loops are only torn down
|
||||
// on a real change — `/resume latest` or `/resume <currentID>` can resolve to
|
||||
// the already-active session, whose loops belong to it, not a "previous" one.
|
||||
previousID := m.activeSession.SessionID
|
||||
m.activeSession = *session
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
if m.providerName == "" {
|
||||
@@ -213,9 +223,16 @@ func (m model) handleResumeCommand(args string) (model, string) {
|
||||
if m.modelName == "" {
|
||||
m.modelName = session.ModelID
|
||||
}
|
||||
loopsCleared := 0
|
||||
if session.SessionID != previousID {
|
||||
m, loopsCleared = m.clearLoopsForSessionSwitch()
|
||||
}
|
||||
|
||||
rows := initialTranscript()
|
||||
rows = appendRow(rows, rowSystem, m.formatResumeSummary(*session, len(events)))
|
||||
if loopsCleared > 0 {
|
||||
rows = appendRow(rows, rowSystem, fmt.Sprintf("Stopped %d loop(s) tied to the previous session.", loopsCleared))
|
||||
}
|
||||
rows = appendTranscriptRowsDedup(rows, transcriptRowsFromSessionEvents(events))
|
||||
m.transcript = rows
|
||||
// Every rehydrated row is settled by construction, so resetting the flush
|
||||
|
||||
@@ -218,6 +218,13 @@ func (m model) statusLine(width int) string {
|
||||
} else if summary := m.backgroundTerminalSummary(); summary != "" {
|
||||
left += separator + zeroTheme.muted.Render(summary)
|
||||
}
|
||||
// Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a
|
||||
// running loop is always visible (hidden during an exit/cancel confirm above).
|
||||
if !m.exitConfirmActive && !m.cancelConfirmActive {
|
||||
if loopSummary := m.loopFooterSummary(); loopSummary != "" {
|
||||
left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary)
|
||||
}
|
||||
}
|
||||
|
||||
rightGroups := []string{}
|
||||
// Context-fill gauge: surface it down to the narrow tier (where it matters
|
||||
|
||||
Reference in new issue
Block a user