Skip to content

fix(tui): /resume finds and resumes this workspace's saved sessions - #959

Merged
ericleepi314 merged 1 commit into
mainfrom
fix/tui-resume-picker
Sep 28, 2026
Merged

ericleepi314 merged 1 commit into
mainfrom
fix/tui-resume-picker

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Summary

After a reinstall, /resume in the TUI showed the Sessions picker with "0 live · 0 resumable / no other sessions", even though the workspace had a saved 788-message session. Three separate defects stacked up to produce that.

  1. The picker never asked the backend. gatewayClient answered session.list locally with an empty list, left as a "Phase 2" stub since Port hermes-agent TUI to clawcodex + Claude Code theme (fix Terminal.app input lag) #572. /resume, /sessions and /switch all open this picker, so it said "0 resumable" in every workspace.
  2. Resuming by id did nothing. session.resume was stubbed to return the current session. /resume <id>, or picking a row, blanked the screen and kept the current conversation. Startup resume and crash recovery go through the same stub.
  3. The backend's session list was unsorted. Session files store updated_at either as a float (the agent-server writer, 3,540 files on the reporting user's machine) or as an ISO string (the older writer, 1,017 files). Sorting that mix raised TypeError inside a blanket except Exception: pass, so list_sessions returned files in directory order, truncated to the limit.

Fix

Backend (agent_server.py)

  • list_sessions sorts on a normalized epoch. Floats are used as-is and ISO strings are parsed. Anything unreadable or non-finite (NaN would also break the JSON reply) falls back to the file's mtime.
  • It now keeps only this workspace's sessions (cwd) and leaves out the live session.
  • One bad file no longer breaks the listing. The call runs off the event loop and clamps limit to 1–200.
  • resume with include_messages returns the conversation as the TUI's transcript rows: prompts, replies, and tool calls.
    • Rows the live turn never painted stay out: injected <system-reminder> prompts (the same rows the web client hides), meta messages, compact summaries, tool results and reasoning items.
    • A tool row sends only the input fields its one-line summary reads.
    • Without the flag, the reply is unchanged for the web gateway and the slash fallback.
  • resume checks that the id is a plain file stem (no path pieces) before touching the disk.
  • resume also accepts a session title (/resume <title>), matched case-insensitively against this workspace's renamed sessions. The title lookup reads every saved session, so it runs off the event loop.
  • The resume reply carries the "Restored N scheduled task(s)" notice, which the TUI's repaint would otherwise erase.
  • New delete_session control (d d in the picker). It reuses the web gateway's id-validated delete and refuses the live session.

TUI

  • session.list now asks the backend's list_sessions for this workspace. A refused list shows its error instead of "0 resumable". The 1.5 s live-session poll stays local.
  • session.resume <id> now calls the real resume control:
    • The transcript repaints, with tool rows summarized by the same toolContext as live rows. Mode, goal and cron notices follow as system rows.
    • The model is refreshed (a resume can restore the saved session's model).
    • A refused resume shows its error.
    • The resume's session.stats fires after the picker's resetSession(), whose contract is that the resume re-stamps the stats. The timer is armed after the last await, so a slow get_settings can't reorder it.
  • system/init publishes gateway.ready once per spawned backend. A resume that flips coordinator mode re-emits init, and a second ready reran startup and created a new session over the repainted transcript.
  • A failed resume with no live session (crash recovery of a session that never saved, or startup resume) now falls back to a fresh session with the reason. Before, sid stayed null and every prompt only queued.
  • session.delete is wired to the backend.

A resume continues under the live session's id, the same as the web gateway.

Verification

Real TUI build and real agent-server in a PTY. The temp config holds one saved session for the workspace and one from another project.

scenario installed main this branch
/resume picker 0 live · 0 resumable 0 live · 1 resumable: ds_e2e_old today 4 msgs prove the multiple-of-four lemma (the other project's session is hidden)
pick the row nothing resumes, turns: 0 ❯ prove the multiple-of-four lemma / Read 1 file / ⏺ The multiple-of-four case holds., turns: 3
resume a coordinator-mode session n/a transcript kept, "Entered coordinator mode…" row, turns: 3 (5 of 5 runs)
backend killed before the first save n/a "could not resume …: session not found", then a fresh usable session
d d on the row n/a row gone, file deleted

On the reporting user's real sessions dir, the workspace listing returns their 788-message session and a 10-message one.

Test plan

  • pytest, tests/server/test_session_resume_picker.py (12):
    • newest-first across both timestamp formats, with a garbage file present
    • a NaN timestamp still produces valid JSON
    • filters to this workspace, excludes the live session, clamps the limit
    • no cwd lists every workspace
    • exact transcript rows, including meta, reasoning and text-block prompts
    • the no-flag reply is unchanged
    • resume by title; unsafe ids and a blank title are refused
    • the cron notice is carried in the reply
    • delete, live-session refusal and unsafe ids
  • vitest, gatewayClient.test.ts (10 new):
    • list via the backend, and a refused list
    • the poll stays local
    • resume rows, toolContext, banner and model refresh
    • a refused resume
    • stats stamped after a late get_settings
    • a single gateway.ready per backend, and a fresh one after a respawn
    • delete, and a refused delete
  • Mutation-checked all 8 original guards; the round-2 guards are pinned by the tests above.
  • Full pytest: 10,833 passed. The one failure, test_headless_keeps_the_damped_wording…, also fails on main. The blank-title test was added after that run started; its file passes 12/12.
  • Full ui-tui vitest: 1,929 passed. The 7 known failures predate this change. cursorDriftRegression and two modelPicker tests are timing-sensitive; they failed only while another project's test run held the machine at load 27, and passed in isolation on this code (modelPicker 14/14 ×3, cursorDrift 4/4).
  • Critic review loop, three rounds.
    • Round 1 found three bugs (B1–B3), the lost cron notice, and the delete gap.
    • Round 2 caught that making the picker's "+ new" clear the backend would overwrite the previous conversation's file. That change is reverted.

Follow-ups (not in this PR)

  • The test suite writes session files into the real ~/.clawcodex/sessions (3,390 of the reporting user's 4,557). The picker is workspace-scoped, so they no longer show there, but each listing still parses them.
  • The picker shows "0 live": with one agent-server session, the current one isn't offered as a live row.
  • A resume continues under the live session's id, so the original row and the continued one both appear later. The web client behaves the same way.
  • Matching on the exact cwd hides sessions started from a subdirectory or a worktree.
  • /new, /clear and the picker's "+ new" overwrite the previous conversation's file after the next turn, because the backend's clear keeps the session id. The picker's "+ new" also leaves the backend conversation in place while the screen goes blank (unchanged here). The fix is to save under a fresh id on clear, as Claude Code does.
  • delete_session only protects this process's own live session. A second TUI in the same workspace could delete the first one's live file. The web client's delete has no guard either.

🤖 Generated with Claude Code

The TUI's Sessions picker (/resume, /sessions, /switch) said "0
resumable" in every workspace. Three defects stacked:
- gatewayClient answered session.list locally with an empty list (a
  "Phase 2" stub since #572).
- session.resume was stubbed to return the current session, so
  resuming by id blanked the screen and kept the old conversation.
- The backend's list_sessions returned an arbitrary slice. Session
  files carry updated_at as a float or an ISO string, and sorting the
  mix raised inside a catch-all.

Backend:
- list_sessions sorts on a normalized epoch (NaN and unreadable values
  fall back to mtime), scopes to the caller's cwd, excludes the live
  session, clamps the limit, and runs off the loop.
- resume can return transcript rows (include_messages): prompts,
  replies and tool calls. Injected reminders, meta messages, tool
  results and reasoning are left out.
- resume validates the id and resolves /resume <title> off the loop;
  a blank title matches nothing.
- The resume reply carries the restored-tasks notice.
- New delete_session control for the picker's `d d`.

TUI:
- session.list and session.resume ride those controls. Tool rows are
  summarized with the live toolContext, and notices are appended.
- The model is refreshed after a resume.
- The resume's session.stats fires after the picker's reset (the timer
  is armed after the last await).
- gateway.ready fires once per spawned backend, so a resume that
  re-sends init can't forge a session over the repaint.
- A failed resume with no live session falls back to a fresh one
  instead of leaving prompts queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Test Results

     5 files   1 021 suites   21m 42s ⏱️
16 104 tests 16 082 ✅ 22 💤 0 ❌
32 178 runs  32 107 ✅ 71 💤 0 ❌

Results for commit 67c4398.

@ericleepi314
ericleepi314 merged commit b241053 into main Sep 28, 2026
8 checks passed
@ericleepi314
ericleepi314 deleted the fix/tui-resume-picker branch September 28, 2026 05:00
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.

1 participant