mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* 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.
78 lines
2.4 KiB
Go
78 lines
2.4 KiB
Go
package sessions
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTitleTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
now := time.Date(2026, 6, 16, 9, 0, 0, 0, time.UTC)
|
|
return NewStore(StoreOptions{
|
|
RootDir: t.TempDir(),
|
|
Now: func() time.Time {
|
|
now = now.Add(time.Second)
|
|
return now
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestUpdateTitle(t *testing.T) {
|
|
store := newTitleTestStore(t)
|
|
session, err := store.Create(CreateInput{Title: "first message title"})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
// An append bumps UpdatedAt and EventCount; UpdateTitle must preserve both.
|
|
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{
|
|
Type: EventMessage,
|
|
Payload: map[string]any{"role": "user", "content": "hi"},
|
|
}); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
before, err := store.Get(session.SessionID)
|
|
if err != nil || before == nil {
|
|
t.Fatalf("get before: %v", err)
|
|
}
|
|
|
|
updated, err := store.UpdateTitle(session.SessionID, " Clean Generated Title ")
|
|
if err != nil {
|
|
t.Fatalf("update title: %v", err)
|
|
}
|
|
if updated.Title != "Clean Generated Title" {
|
|
t.Fatalf("title not trimmed/stored: %q", updated.Title)
|
|
}
|
|
if updated.UpdatedAt != before.UpdatedAt {
|
|
t.Fatalf("UpdatedAt must not change on retitle: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
|
|
}
|
|
if updated.EventCount != before.EventCount {
|
|
t.Fatalf("EventCount changed: before=%d after=%d", before.EventCount, updated.EventCount)
|
|
}
|
|
|
|
persisted, err := store.Get(session.SessionID)
|
|
if err != nil || persisted == nil {
|
|
t.Fatalf("get after: %v", err)
|
|
}
|
|
if persisted.Title != "Clean Generated Title" {
|
|
t.Fatalf("persisted title = %q, want %q", persisted.Title, "Clean Generated Title")
|
|
}
|
|
|
|
// A blank title is rejected and must not erase the existing one.
|
|
if _, err := store.UpdateTitle(session.SessionID, " "); err == nil {
|
|
t.Fatal("expected a blank title to be rejected")
|
|
}
|
|
if got, _ := store.Get(session.SessionID); got == nil || got.Title != "Clean Generated Title" {
|
|
t.Fatalf("title must survive a rejected blank update, got %#v", got)
|
|
}
|
|
|
|
// An unchanged title is a no-op (still succeeds).
|
|
if _, err := store.UpdateTitle(session.SessionID, "Clean Generated Title"); err != nil {
|
|
t.Fatalf("no-op retitle should succeed: %v", err)
|
|
}
|
|
|
|
// An invalid session id is rejected.
|
|
if _, err := store.UpdateTitle("../escape", "whatever"); err == nil {
|
|
t.Fatal("expected an invalid session id to be rejected")
|
|
}
|
|
}
|