Skip to content

Add session titles, /rename, and a type-to-filter resume picker - #326

Open
mohitpaddhariya wants to merge 6 commits into
huggingface:mainfrom
mohitpaddhariya:feat/session-naming-rename
Open

Add session titles, /rename, and a type-to-filter resume picker#326
mohitpaddhariya wants to merge 6 commits into
huggingface:mainfrom
mohitpaddhariya:feat/session-naming-rename

Conversation

@mohitpaddhariya

@mohitpaddhariya mohitpaddhariya commented Jun 14, 2026

Copy link
Copy Markdown

Summary

Implements #325. Makes local CLI sessions easy to name, find, and resume.

  • Session titles + /rename + auto-title — every session gets a short human-readable title (auto-generated from the conversation after the first turn, with a pure-Python fallback), and /rename <name> lets you set it explicitly.
  • Standardized storage — session logs move from a CWD-relative ./session_logs to an XDG path ($XDG_DATA_HOME/ml-intern/sessions), overridable via config or ML_INTERN_SESSION_DIR, with a legacy fallback so existing checkouts are undisturbed.
  • Readable filenamessession_<slug>_<uuid8>_<timestamp>.json instead of a bare UUID.
  • Type-to-filter resume picker/resume opens an arrow-key picker (filter by title/preview/model), shows titles, fixes the sort order, and dedupes a resumed conversation so it never appears twice. /resume <index|id|path> still works as a fast path.

How it works

1. Titling: /rename + auto-title

flowchart TD
  A["First user turn completes"] --> B{"Already titled<br/>or renamed?"}
  B -- no --> C["Auto-title: ask the model<br/>for a 3-6 word title"]
  C -- success --> T["session_title"]
  C -- "error / empty" --> D["Fallback: slug of<br/>first message"]
  D --> T
  B -- yes --> T
  RN["/rename name"] --> T
  T --> P["persist_title()"]
  P --> P1["rename log in place +<br/>refresh title inside the JSON"]
  P --> P2["or save a fresh titled log<br/>if none exists yet"]
Loading

2. Storage: resolve_session_log_dir(config)

flowchart TD
  S0["resolve_session_log_dir(config)"] --> S2{"config.session_log_dir set?"}
  S2 -- yes --> U1["use it"]
  S2 -- no --> S3{"$ML_INTERN_SESSION_DIR set?"}
  S3 -- yes --> U2["use it"]
  S3 -- no --> S4{"./session_logs exists in cwd?"}
  S4 -- yes --> U3["use legacy ./session_logs"]
  S4 -- no --> U4["XDG: ~/.local/share/ml-intern/sessions"]
Loading

3. Filenames

flowchart TD
  N0["save a session log"] --> N1{"has a usable title?"}
  N1 -- yes --> N2["session_&lt;slug&gt;_&lt;uuid8&gt;_&lt;timestamp&gt;.json"]
  N1 -- no --> N3["session_&lt;uuid&gt;_&lt;timestamp&gt;.json (legacy)"]
Loading

4. Resume: /resume

flowchart TD
  R1["/resume"] --> R2["list_session_logs from resolved dir"]
  R2 --> R3["sort by one tz-aware timestamp"]
  R3 --> R4["dedupe by session_id (keep newest)"]
  R4 --> R5["type-to-filter arrow-key picker"]
  R5 --> R6["restore_session_from_log"]
  R6 --> R7["carry session_title across resume"]
Loading

Key design points

  • One canonical timestamp for both sorting and display (session_end_timesession_start_time → file mtime), tz-aware, so the list is genuinely newest-first and labels never look scrambled.
  • Dedupe by session_id — a resumed-and-continued conversation forks the save path into a new-timestamp file; the listing collapses these to the newest entry so the same conversation never shows twice.
  • persist_title() — setting a title (auto or /rename) renames the active log in place and refreshes the title stored inside the JSON, or saves a fresh titled log when none exists yet (e.g. right after a resume), so /resume reflects the new name immediately.
  • One resolver (resolve_session_log_dir) is used by every reader and writer, so reads and writes can never diverge.
  • Secret-like tokens are stripped from titles before they reach a filename or the JSON.

Testing

  • New unit tests: test_title.py, test_session_title.py, test_session_dir.py, test_session_filename.py, test_session_picker.py, plus additions to test_session_resume.py.
  • Covers auto-title + fallback, /rename, XDG resolution + precedence, titled/legacy filenames, in-place rename + title refresh, sort/display agreement, and dedupe (including not merging genuinely distinct sessions).
  • Full unit suite passes locally.

Screenshots

Auto-generated title after the first turn

Auto-title message shown after the first turn

/resume — type-to-filter picker

New /resume type-to-filter picker

/rename — renaming a session

/rename command output

Closes #325

Sessions now carry a human-readable title so they can be found again,
the way ChatGPT and Claude name conversations.

- Add a persisted session_title field to Session (in get_trajectory, reset
  on /new) plus a _title_user_set flag so an explicit rename is never
  clobbered by auto-titling.
- New agent/core/title.py: generate_conversation_title asks the active model
  for a 3-6 word title (tiny token budget, no telemetry/billing impact) and
  falls back to a pure-Python slug of the first user message on any error.
  Long secret-like tokens are stripped before a title reaches disk.
- Auto-title fires once, fire-and-forget, after the first completed turn,
  so a slow or failing title never blocks or breaks a turn.
- Add the /rename <name> command and list it in /help.

Part of huggingface#325.
Session logs were written to ./session_logs relative to the launch
directory, so they scattered across folders and /resume only saw the
current cwd's logs.

- Add resolve_session_log_dir(config): config.session_log_dir > the
  ML_INTERN_SESSION_DIR env var > legacy ./session_logs if it already
  exists > XDG default ($XDG_DATA_HOME/ml-intern/sessions, defaulting to
  ~/.local/share/ml-intern/sessions).
- Route save_trajectory_local, the /resume picker, and the failed-upload
  retry scanner through the one resolver so reads and writes never diverge.
- Add a session_log_dir config field; keep ./session_logs as a back-compat
  fallback so existing checkouts are undisturbed.

Part of huggingface#325.
Splice a slugified session title into the log filename so saved sessions
are human-scannable: session_<slug>_<uuid8>_<timestamp>.json, falling back
to the legacy session_<uuid>_<timestamp>.json when there's no usable title.

- Add slugify() in title.py (secret-stripped, lowercase, length-capped).
- Factor filename construction into Session._session_log_filename.
- When a title is set (auto-title or /rename), rename the active log file
  in place via apply_title_to_local_file so even a single-turn session ends
  up titled, preserving the original timestamp and leaving no orphan/dup.
- Keep the session_ prefix + .json suffix so the upload-retry glob matches.

Part of huggingface#325.
Replace the 'type a session number' /resume prompt with a type-to-filter
arrow-key picker (prompt_toolkit, no new dependency) and surface session
titles, while fixing the listing's order and duplicate rows.

- New agent/utils/session_picker.py: fzf-style picker — filter by
  title/preview/model, up/down to move, Enter to select, Esc to cancel,
  with a scrolling viewport for long lists. /resume <index|id|path> stays
  as a non-interactive fast path and headless fallback.
- Show the session title in each row (preview fallback for older logs) and
  in the resume confirmation; skip slash-command first messages in previews.
- Sort and display now use one canonical, tz-aware timestamp (session_end_time
  then start_time, mtime as tiebreaker/fallback), so the list is genuinely
  newest-first and labels never look scrambled. session_end_time is now
  tz-aware.
- Dedupe the listing by session_id (newest kept) so a resumed-and-continued
  conversation — which forks the save path into a new-timestamp file — no
  longer shows up multiple times. Namespace the legacy no-session_id fallback
  so it can't wrong-merge.
- /rename now persists immediately via Session.persist_title: rename the
  active log in place AND refresh the persisted session_title, or save a fresh
  titled log when none exists yet (e.g. right after a resume forked the path),
  so /resume reflects the new name without waiting for the next turn.
- Carry session_title across resume.

Closes huggingface#325.
@mohitpaddhariya
mohitpaddhariya marked this pull request as ready for review June 14, 2026 14:21
@mohitpaddhariya

Copy link
Copy Markdown
Author

Hi @lewtun and @akseljoonas, this PR (#326) implements #325 and includes:

  • Session titles
  • /rename
  • Automatic title generation
  • XDG-compliant session storage
  • Readable session filenames
  • A type-to-filter /resume picker

The full write-up, diagrams, and screenshots are in the PR description, and the changes are covered by unit tests.

Whenever you have a chance, I'd appreciate a review and merge consideration. Happy to make any adjustments if needed.

Note: The red "Claude PR Review" check is expected to fail on this PR because it originates from a fork and only has read-only token permissions. It does not indicate a problem with the code or tests.

mohitpaddhariya

This comment was marked as low quality.

- Auto-title race: snapshot a per-conversation epoch when spawning the
  fire-and-forget title task and bail if /new or /resume rotated the
  conversation during the title LLM call, so a stale title can't be
  stamped onto a different session.
- Fire the auto-title trigger from the usage-threshold / YOLO / abandon
  completion paths too, so a first turn that paused for an approval still
  gets titled instead of staying permanently untitled.
- Hold a strong reference to the title task so it can't be GC'd mid-await.
- Scrub titles through redact.scrub_string on the auto-title path, the
  /rename path, and the persisted JSON/filename, closing the gap where
  AWS key ids, Bearer tokens, and NAME=value dumps reached disk via
  session_title.
- Escape titles before rendering them as Rich markup.
- Union-read a legacy ./session_logs alongside the resolved dir so
  pre-XDG-migration sessions stay visible in /resume, and drop the
  divergent cwd-relative directory defaults that bypassed the resolver.
- Guard the title rename and trajectory save with a lock so a heartbeat
  save on the worker thread can't resurrect a pre-title log file.
@mohitpaddhariya

Copy link
Copy Markdown
Author

Pushed a follow-up commit (fd3ae7c) that hardens this PR based on a self-review pass before merge. Everything stays within the existing scope — titles / /rename / auto-title, storage, and resume — no new surface.

Titling

  • Fixed a race in the fire-and-forget auto-title task: it now snapshots a per-conversation epoch and bails if /new or /resume rotated the conversation during the title LLM call, so a stale title can't be written onto a different session.
  • Auto-title now also fires from the usage-threshold / YOLO / approval-abandon completion paths, so a first turn that paused for an approval still gets titled instead of staying permanently untitled.
  • The title task keeps a strong reference now (same pattern as _heartbeat_tasks in telemetry.py), so it can't be GC'd mid-await.

Secret stripping (the "secret-like tokens are stripped from titles" design point)

  • Titles now route through the shared redact.scrub_string on the auto-title path, the /rename path, and the persisted JSON/filename. The old 30-char heuristic missed structured secrets the project redactor catches (AWS key ids, Bearer tokens, NAME=value dumps); those no longer reach the filename slug or the session_title field. Scope is local-disk only — session_title isn't in either upload payload.
  • Titles are escape()d before being rendered as Rich markup.

Storage (the "one resolver" design point)

  • /resume now union-reads a legacy ./session_logs alongside the resolved dir, so sessions created before the XDG move stay visible regardless of launch cwd (non-destructive — writes still go to the resolved dir).
  • Dropped the divergent cwd-relative directory defaults that bypassed resolve_session_log_dir, keeping the single-resolver invariant honest.

Concurrency

  • Guarded the title rename + trajectory save with a lock so a heartbeat save on the worker thread can't resurrect a pre-title log file.

Full unit suite passes locally (542).

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown

Claude finished @lewtun's task in 3m 19s —— View job


Review — PR #326 (session titles, /rename, resume picker)

No blocking issues — 1 P1, 4 P2. The concurrency/race concerns the author called out are genuinely handled: the epoch + session-id guard in _generate_and_set_title (agent/core/agent_loop.py:1289-1296), the _save_lock around rename+save (agent/core/session.py:699, :809), the strong task refs (agent/core/agent_loop.py:1255), and path-traversal safety via slugify's [a-z0-9-] allowlist (agent/core/title.py:86/rename ../../etc/passwd → slug etc-passwd). Storage resolution routes through the single resolve_session_log_dir for every reader/writer as claimed. Test coverage for the new behavior is solid.


P1 — Auto-title LLM call is unmetered, bypassing the usage/YOLO accounting

agent/core/title.py:186 issues a real billable acompletion against the user's HF token, but the call is intentionally detached from session telemetry (title.py:13-15). Because it never lands in session.logged_events as an llm_call, it's invisible to total_cost_usd (agent/core/session.py:635-639) and to summarize_usage_events (session.py:643) — the same metrics that drive the recently-added YOLO cap (#313) and usage-threshold approvals (#310).

In practice the impact is small (max_completion_tokens=24, fires once per session), and the design doc acknowledges it. Flagging it because "an LLM call the meter can't see" is exactly the kind of gap the usage-cap work was meant to close, and it'll compound if the title path ever grows (e.g. retitling). Two low-cost mitigations: record the call's cost into the session like other LLM calls, or at minimum pass an explicit short timeout= to acompletion so a stalled router request can't keep the strong-referenced task (agent_loop.py:1255) alive on the default ~10-min LiteLLM timeout. Acceptable to defer to a follow-up. Fix this →


P2 — Orphaned older-timestamp files for resumed sessions are never cleaned

list_session_logs dedupes by session_id keeping the newest (session_resume.py:165-168), which correctly stops a continued conversation showing twice. But the older file stays on disk forever — the listing just hides it. Over many resumes this grows sessions/ unbounded. Not a regression (pre-PR there was no cleanup either) and the dedupe makes it invisible, so purely informational.

P2 — Resumed legacy (untitled) sessions can never auto-title

_maybe_spawn_auto_title bails when turn_count != 0 (agent_loop.py:1316), and restore_session_from_log sets turn_count from the restored messages (session_resume.py:351). A pre-titles session resumed and continued therefore stays permanently untitled unless the user runs /rename. Likely intended (don't retro-title old chats), noting it so it's a conscious choice.

P2 — Picker viewport math counts entries, not rendered lines

pick_session_interactive uses wrap_lines=True (session_picker.py:167) but paginates by entry count against _MAX_VISIBLE = 8 (session_picker.py:88, :100). A long wrapped title/preview can push the visible block past the intended height. Cosmetic.

P2 — .json.tmp staging files can be orphaned on a crash

The atomic-write pattern stages to <file>.json.tmp then replace()s (session.py:823-826, :730-733). A crash between the two leaves a .tmp that neither the picker glob (*.json) nor the retry glob (session_*.json) matches — so it lingers but is harmless. Pre-existing pattern; mentioning for completeness.


Nice work — the hardening pass (epoch guard, secret scrubbing through the shared redact.scrub_string, single-resolver invariant, dedupe-by-id) is well-reasoned and the tests exercise the tricky paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Add session naming with /rename and improve resume picker UX

2 participants