Files
zero/internal/agent/no_progress_stop_test.go
KRATOS 6df5c2b4cb Improve /resume: interactive session picker + model-generated titles (#224)
* tui: interactive /resume session picker + resume fixes

- /resume (no arg) now opens an interactive picker like /model and /provider:
  one row per resumable session showing the title and session id (+ relative
  age); arrow to highlight, type to filter, enter to load. /resume <id> and
  /resume latest still resolve directly.
- Resume now loads the rehydrated (compaction-aware) event view instead of the
  raw log, so a resumed session honors a prior /compact — matching the CLI's
  `zero exec --resume` and the in-TUI /compact reload. Falls back to raw on a
  rehydrate error.
- The resume summary reports the model/provider the continuation will actually
  use (the active ones), noting the session's recorded model/provider when it
  differs, instead of implying a switch that doesn't happen.
- Drop the dead, self-contradictory resumeText(arg) branch (it was unreachable
  and its hint was circular); resumeText is now the no-session/error fallback.

Tests: session picker opens with title+id rows and hydrates on selection;
resume honors prior compaction (rehydrated, not raw events).

* tui: lead /resume picker rows with a precise timestamp

Same-titled sessions (e.g. the same first prompt run several times) looked like
duplicates because the row showed only the identical title plus a long id that
truncated the age away. Lead each row's label with a precise local timestamp
(HH:MM:SS today, "Jan _2 15:04" earlier this year, else the date) so distinct
sessions are obviously distinct; the full id stays as the right-aligned meta and
is what selection resolves. sessionWhen parses the RFC3339 UpdatedAt.

* tui: hide empty/no-output sessions from the /resume picker

Most of a heavily-retried prompt's sessions are empty failed runs — just the
prompt plus the no-output guardrail stop ("Agent stopped … with no output …"),
with no assistant text and no tool calls. They have nothing to resume and flood
the picker with identical-looking rows. Skip any session with no resumable
content (no tool call/result and no real non-user message) from the picker; they
stay on disk and are never deleted. Adds agent.IsNoProgressStop to recognize the
stop message from a recorded event, and a test that the picker hides an empty
run while keeping a real one.

* feat(tui): model-generated session titles (auto + /retitle backfill)

Sessions were titled from their first user message, so prompts that
started the same way (or empty/failed runs) looked identical in the
/resume picker. Generate a concise, specific title with the active
provider instead.

- sessions.Store.UpdateTitle(id, title): rewrites only Title under the
  per-session lock, re-reading the latest metadata first; leaves
  UpdatedAt untouched (a retitle is not activity and must not reorder
  the resumable list), rejects a blank title, and no-ops an unchanged
  one.
- Title generator: a bounded digest of the conversation (user/assistant
  text + tool names, per-message and total caps, skipping the no-output
  guardrail stop) is sent as a one-shot completion; the response is
  cleaned (first content line, quotes/markup/"Title:" label stripped,
  word- and rune-capped) before storing.
- Auto-title going forward: after a successful turn, a session still
  carrying its default first-message title gets a title in the
  background, at most once per session.
- /retitle: backfills existing resumable sessions that still have a
  first-message title, one at a time, skipping empty/failed runs and
  already-named sessions, with kickoff and completion status lines.

Generation runs off the Update goroutine; failures are non-fatal (the
first-message title simply stays). Adds store + TUI unit tests
(UpdateTitle semantics, digest/cleaning, auto-title one-shot, backfill
candidate selection). gofmt/vet/build(host+linux+windows)/test -race/
staticcheck all green.

* address review: tighten no-progress detection, fix fallback errors + nil guard

Three review findings on the /resume work:

- IsNoProgressStop matched any content CONTAINING the no-output marker
  substring (strings.Contains), so a legitimate assistant/tool message that
  quoted the marker could be misread as a failed empty run — wrongly hiding a
  real session from the /resume picker and skipping its title generation. Now
  it requires the full answer structure (prefix + marker + suffix); a quote no
  longer matches.
- resumeEvents returned the earlier rehydration error when the raw ReadEvents
  fallback itself failed, masking the actual fallback failure. Returns rawErr.
- resumeText dereferenced m.sessionStore unconditionally; a nil store (fallback/
  test paths) would panic. Now renders a safe "store unavailable" message.
  (newSessionPicker already guarded nil.)

Tests: IsNoProgressStop accepts the real answer and rejects a quoting message /
bare marker / prefix- or suffix-only; resumeText with a nil store returns the
safe message instead of panicking. Full gate green.

* address review (Vasanth): structural no-progress check, retryable auto-title, nil-store guard, deep resume assertion

- guardrails: IsNoProgressStop now matches the exact structure noOutputStopAnswer
  emits — prefix + "<int> turns " + marker + " " + suffix — instead of
  prefix && contains(marker) && suffix. A genuine message that merely quotes the
  marker amid other prose is no longer misclassified as a failed empty run (which
  would wrongly hide a real session from /resume and skip its title generation).
  Adds the hostile-input regression cases (quoted marker, text between
  marker/suffix, non-integer turn count).
- session_title: release the optimistic titledSessions gate when a generation
  FAILS (provider error, empty title, store write error) so a later turn or
  /retitle can retry; success keeps the one-shot gate. Kept the optimistic mark
  (rather than mark-on-success) to preserve the no-double-fire guarantee while a
  generation is in flight.
- session_title: guard maybeAutoTitleActiveSession against a nil sessionStore
  (the title cmd calls store.UpdateTitle) — mirrors the resume nil-store guards.
- model_test: assert resumed sessionEvents with reflect.DeepEqual against the
  rehydrated context, not just slice length, so a reordered/substituted-but-
  same-length regression is caught.
2026-06-17 12:41:20 +05:30

37 lines
1.9 KiB
Go

package agent
import "testing"
func TestIsNoProgressStopRequiresFullStructure(t *testing.T) {
// The actual guardrail answer (any turn count) is recognized.
for _, turns := range []int{1, 3, 12} {
if !IsNoProgressStop(noOutputStopAnswer(turns)) {
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized", turns)
}
}
// A legitimate message that merely QUOTES the marker must NOT be classified as
// a no-progress stop — that would wrongly hide a real session from /resume.
rejected := []string{
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
"with no output (no visible text and no tool calls)", // bare marker, no prefix/suffix
"Agent stopped after 3 turns.", // prefix only
"to avoid consuming tokens without making progress.", // suffix only
"Here is your e-commerce site.", // unrelated
"",
// Hostile input: prefix + marker + suffix ALL present, but the marker is
// quoted mid-sentence rather than being the guard's own structured answer.
// A loose prefix/contains/suffix check misclassifies this.
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
// Arbitrary text wedged between the marker and the suffix.
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
// Non-integer "turn count" between the prefix and the marker.
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
}
for _, content := range rejected {
if IsNoProgressStop(content) {
t.Fatalf("content must not be classified as a no-progress stop: %q", content)
}
}
}