1818from rich .live import Live
1919
2020from .. import config
21- from ..attachments import image_placeholder , parse_at_references , reattach_images
21+ from ..attachments import image_placeholder , parse_at_references
2222from ..commands import find_command
2323from ..models import ImagePart , Message , TextPart
2424from ..persistence import (
2525 SessionPersistence ,
2626 escape_role_headers ,
27- split_role_header ,
27+ find_session_by_title ,
28+ parse_saved_body ,
2829 title_from_filename ,
29- unescape_role_header ,
3030)
3131from ..prompts import RESERVED_AGENT_NAME , discover_agents
3232from .input import _history_path
@@ -632,7 +632,7 @@ def _run_sessions(self) -> None:
632632 model = meta .get ("python-agent-harness--model" , "?" )
633633 project = meta .get ("python-agent-harness--project-dir" , "?" )
634634 body = SessionPersistence .strip_metadata (text )
635- n_msgs = len (self . _parse_saved_body (body ))
635+ n_msgs = len (parse_saved_body (body ))
636636 self .console .print (
637637 f" { basename :50s} model={ model :20s} project={ project } { n_msgs } messages"
638638 )
@@ -655,7 +655,7 @@ def _run_restore(self, arg: str) -> None:
655655 else :
656656 # Try title-based matching: find sessions whose filename
657657 # contains the argument as a case-insensitive substring
658- path = self . _find_session_by_title (arg )
658+ path = find_session_by_title (arg )
659659 if not path :
660660 self .console .print (
661661 "[yellow]no session found "
@@ -675,7 +675,7 @@ def _run_restore(self, arg: str) -> None:
675675 meta = SessionPersistence .parse_metadata (text )
676676 body = SessionPersistence .strip_metadata (text )
677677 # Rebuild conversation history from the saved markdown format
678- messages = self . _parse_saved_body (body )
678+ messages = parse_saved_body (body )
679679 # Round timestamps are persisted in the metadata block; restore
680680 # them so the dump separators keep their HH:MM:SS times.
681681 round_times = meta .get ("python-agent-harness--round-times" )
@@ -785,97 +785,3 @@ def _switch_input_history(self, project_dir: str) -> None:
785785 # reset() cancels the pending history-load task and repopulates
786786 # the working lines from the new history on the next render
787787 buffer .reset ()
788-
789- @staticmethod
790- def _parse_saved_body (body : str ) -> list [Message ]:
791- """Parse a saved session body back into Message objects.
792-
793- The save format is markdown with **role**: content blocks
794- separated by blank lines.
795-
796- ``tool`` and ``system`` blocks are dropped: the saved markdown
797- does not keep ``tool_call_id``/``name`` (assistant tool calls are
798- flattened to plain text), so a restored ``role="tool"`` message
799- would form an API-invalid payload (a tool message with no
800- preceding assistant ``tool_calls``). A restored ``system``
801- message would duplicate the live system prompt the client
802- prepends on every request. The following assistant reply
803- already summarizes the results, so dropping them loses no
804- essential context.
805-
806- Body lines that merely look like a block header are escaped by
807- the renderer (see `escape_role_headers`) and unescaped here, so
808- a message quoting this format no longer splits into extra
809- messages. Sessions saved before escaping existed can still
810- split — that ambiguity is in the file, not in this parser.
811- """
812- messages : list [Message ] = []
813- current_role : str | None = None
814- current_lines : list [str ] = []
815-
816- def _flush (role : str , lines : list [str ]) -> None :
817- content = "\n " .join (lines ).strip ()
818- if not content :
819- return
820- # A leading image-attachment placeholder (written on save)
821- # is re-attached when the file still exists, so the restored
822- # message is multimodal again; otherwise it stays as text.
823- new_content , parts = reattach_images (content )
824- if parts :
825- remainder = new_content .split ("\n " , 1 )
826- rest_text = remainder [1 ].strip () if len (remainder ) > 1 else ""
827- content_parts : list = []
828- if rest_text :
829- content_parts .append (TextPart (text = rest_text ))
830- content_parts .extend (parts )
831- messages .append (Message (role = role , content = content_parts ))
832- else :
833- messages .append (Message (role = role , content = content ))
834-
835- for line in body .splitlines ():
836- # Check for a role header: **user**: ... or **assistant**: ...
837- header = split_role_header (line )
838- if header is not None :
839- role , rest = header
840- # Save the previous block (tool blocks lose their
841- # tool_call_id/name; system blocks would duplicate the
842- # live system prompt the client prepends per request)
843- if current_role is not None and current_role not in ("tool" , "system" ):
844- _flush (current_role , current_lines )
845- current_role = role
846- current_lines = [unescape_role_header (rest )]
847- continue
848- current_lines .append (unescape_role_header (line ))
849-
850- # Don't forget the last block (tool/system blocks dropped, see above)
851- if current_role is not None and current_role not in ("tool" , "system" ):
852- _flush (current_role , current_lines )
853-
854- return messages
855-
856- @staticmethod
857- def _find_session_by_title (query : str ) -> str | None :
858- """Find a session file by title substring (case-insensitive).
859-
860- Matches against the full filename, the filename without .md,
861- and the derived title. Returns the most recent match, or None.
862- """
863- query_lower = query .lower ()
864- # Strip .md from query if present, for cleaner substring matching
865- query_stem = query_lower [:- 3 ] if query_lower .endswith (".md" ) else query_lower
866- files = SessionPersistence .list_sessions () # already sorted by mtime desc
867- for f in files :
868- basename = os .path .basename (f )
869- basename_lower = basename .lower ()
870- # Exact basename match (with or without .md)
871- if basename_lower == query_lower or basename_lower == query_lower + ".md" :
872- return f
873- # Substring match against filename (minus .md)
874- name_part = basename [:- 3 ] if basename .endswith (".md" ) else basename
875- if query_stem in name_part .lower ():
876- return f
877- # Match against derived title (dashes → spaces)
878- title = title_from_filename (f )
879- if title and query_stem in title .lower ():
880- return f
881- return None
0 commit comments