From f78a7b276ea3048a3590218dde6c84a7260619b4 Mon Sep 17 00:00:00 2001 From: AEON Core Date: Wed, 29 Jul 2026 23:22:32 +0200 Subject: [PATCH 1/8] fix(oneshot): honor resume state --- hermes_cli/main.py | 12 +++++++ hermes_cli/oneshot.py | 82 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 624d0f10109f..bf0e33412ce4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -180,6 +180,9 @@ def _run_and_exit_oneshot( provider: object = None, toolsets: object = None, usage_file: object = None, + resume_session_id: object = None, + continue_last: object = None, + restore_resume_cwd: bool = True, ) -> None: try: from hermes_cli.oneshot import run_oneshot @@ -190,6 +193,9 @@ def _run_and_exit_oneshot( provider=provider, toolsets=toolsets, usage_file=usage_file, + resume_session_id=resume_session_id, + continue_last=continue_last, + restore_resume_cwd=restore_resume_cwd, ) except KeyboardInterrupt: rc = 130 @@ -10888,6 +10894,9 @@ def _try_termux_fast_cli_launch() -> bool: provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), usage_file=getattr(args, "usage_file", None), + resume_session_id=getattr(args, "resume", None), + continue_last=getattr(args, "continue_last", None), + restore_resume_cwd=not getattr(args, "no_restore_cwd", False), ) if (args.resume or args.continue_last) and args.command is None: @@ -12519,6 +12528,9 @@ def _add_session_filter_args(p, default_older_help): provider=getattr(args, "provider", None), toolsets=getattr(args, "toolsets", None), usage_file=getattr(args, "usage_file", None), + resume_session_id=getattr(args, "resume", None), + continue_last=getattr(args, "continue_last", None), + restore_resume_cwd=not getattr(args, "no_restore_cwd", False), ) # Handle top-level --resume / --continue as shortcut to chat diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index f13fe64029d5..887231e65e01 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -173,6 +173,9 @@ def run_oneshot( provider: Optional[str] = None, toolsets: object = None, usage_file: Optional[str] = None, + resume_session_id: Optional[str] = None, + continue_last: object = None, + restore_resume_cwd: bool = True, ) -> int: """Execute a single prompt and print only the final content block. @@ -187,6 +190,11 @@ def run_oneshot( cost, token counts, model, api_calls) is written there after the run — even when the run fails — so pipelines can account for spend per invocation. + resume_session_id: Existing session ID or title whose history and + durable row should receive this turn. + continue_last: ``True`` for the latest CLI session, or a session title. + restore_resume_cwd: Restore the resumed session's recorded workspace + before constructing the agent. Returns the exit code. The caller owns process termination. """ @@ -248,6 +256,9 @@ def run_oneshot( provider=provider, toolsets=explicit_toolsets, use_config_toolsets=use_config_toolsets, + resume_session_id=resume_session_id, + continue_last=continue_last, + restore_resume_cwd=restore_resume_cwd, ) except BaseException as exc: # noqa: BLE001 # Capture anything that escapes the agent (including OSError @@ -310,12 +321,70 @@ def _create_session_db_for_oneshot(): return None +def _load_oneshot_resume( + session_db, + *, + resume_session_id: Optional[str], + continue_last: object, + restore_resume_cwd: bool, +) -> tuple[Optional[str], Optional[list[dict]]]: + """Resolve and load one persisted session for a one-shot continuation.""" + if not (resume_session_id or continue_last): + return None, None + if session_db is None: + raise RuntimeError("Session database unavailable; cannot resume in one-shot mode.") + + target = str(resume_session_id or "").strip() + if not target and isinstance(continue_last, str): + target = continue_last.strip() + if not target and continue_last: + recent = session_db.search_sessions(source="cli", limit=1) + if not recent: + raise ValueError("No previous CLI session found to continue.") + target = recent[0]["id"] + + session_meta = session_db.get_session(target) + if not session_meta: + title_match = session_db.resolve_session_by_title(target) + if title_match: + target = title_match + session_meta = session_db.get_session(target) + if not session_meta: + raise ValueError(f"Session not found: {target}") + + resolved_session_id = session_db.resolve_resume_session_id(target) or target + if resolved_session_id != target: + session_meta = session_db.get_session(resolved_session_id) + if not session_meta: + raise ValueError(f"Session not found: {resolved_session_id}") + + conversation_history, _display_history = session_db.get_resume_conversations( + resolved_session_id + ) + conversation_history = [ + message + for message in conversation_history + if message.get("role") != "session_meta" + ] + session_db.reopen_session(resolved_session_id) + + if restore_resume_cwd: + saved_cwd = str(session_meta.get("cwd") or "").strip() + if saved_cwd and os.path.isdir(saved_cwd): + os.chdir(saved_cwd) + + return resolved_session_id, conversation_history + + def _run_agent( prompt: str, model: Optional[str] = None, provider: Optional[str] = None, toolsets: object = None, use_config_toolsets: bool = True, + resume_session_id: Optional[str] = None, + continue_last: object = None, + restore_resume_cwd: bool = True, ) -> tuple[str, dict]: """Build an AIAgent exactly like a normal CLI chat turn would, then run a single conversation. Returns ``(final_response, run_result)``.""" @@ -416,6 +485,13 @@ def _run_agent( # os._exit and skips finalizers, so an un-closed connection here would leak. agent = None try: + resolved_session_id, conversation_history = _load_oneshot_resume( + session_db, + resume_session_id=resume_session_id, + continue_last=continue_last, + restore_resume_cwd=restore_resume_cwd, + ) + # Read the effective fallback chain from profile config so oneshot # workers honour the same merge semantics as interactive CLI and # gateway sessions. @@ -432,6 +508,7 @@ def _run_agent( quiet_mode=True, platform="cli", session_db=session_db, + session_id=resolved_session_id, credential_pool=runtime.get("credential_pool"), fallback_model=_fb or None, # Interactive callbacks are intentionally NOT wired beyond this @@ -454,7 +531,10 @@ def _run_agent( agent.stream_delta_callback = None agent.tool_gen_callback = None - result = agent.run_conversation(prompt) + result = agent.run_conversation( + prompt, + conversation_history=conversation_history, + ) return (result.get("final_response") or "", result) finally: # Ordering deliberately mirrors gateway/run.py:_cleanup_agent_resources, From 6d0d3e39d9c7fe10ae9fde37eb90d2bdbc45b740 Mon Sep 17 00:00:00 2001 From: SE87H <297523205+SE87H@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:57:23 +0200 Subject: [PATCH 2/8] fix(oneshot): scope continue to workspace --- hermes_cli/oneshot.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 887231e65e01..be8be0e617d3 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -23,6 +23,7 @@ import logging import os +import subprocess import sys from contextlib import redirect_stderr, redirect_stdout from pathlib import Path @@ -338,7 +339,16 @@ def _load_oneshot_resume( if not target and isinstance(continue_last, str): target = continue_last.strip() if not target and continue_last: - recent = session_db.search_sessions(source="cli", limit=1) + recent = [] + workspace_key = _resolve_oneshot_workspace_key() + if workspace_key: + recent = session_db.search_sessions( + source="cli", + limit=1, + workspace_key=workspace_key, + ) + if not recent: + recent = session_db.search_sessions(source="cli", limit=1) if not recent: raise ValueError("No previous CLI session found to continue.") target = recent[0]["id"] @@ -376,6 +386,27 @@ def _load_oneshot_resume( return resolved_session_id, conversation_history +def _resolve_oneshot_workspace_key() -> Optional[str]: + """Return the current repo root, or CWD outside a Git workspace.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + return os.path.abspath(result.stdout.strip()) + except Exception: + pass + try: + return os.getcwd() + except Exception: + return None + + def _run_agent( prompt: str, model: Optional[str] = None, From 8239b10d8a30ac8915021ff68ca5ff6784503662 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 09:54:19 +0200 Subject: [PATCH 3/8] fix(oneshot): fail when resumed workspace is unavailable --- hermes_cli/oneshot.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index be8be0e617d3..11549752ce64 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -376,13 +376,24 @@ def _load_oneshot_resume( for message in conversation_history if message.get("role") != "session_meta" ] - session_db.reopen_session(resolved_session_id) if restore_resume_cwd: saved_cwd = str(session_meta.get("cwd") or "").strip() - if saved_cwd and os.path.isdir(saved_cwd): - os.chdir(saved_cwd) + if saved_cwd: + if not os.path.isdir(saved_cwd): + raise FileNotFoundError( + "Recorded session working directory is unavailable: " + f"{saved_cwd}" + ) + try: + os.chdir(saved_cwd) + except OSError as exc: + raise RuntimeError( + "Failed to restore recorded session working directory: " + f"{saved_cwd}" + ) from exc + session_db.reopen_session(resolved_session_id) return resolved_session_id, conversation_history From a0b7ab3d71a50d7da19ab2cc9c31393b0ad36a93 Mon Sep 17 00:00:00 2001 From: SE87H Date: Thu, 30 Jul 2026 09:54:34 +0200 Subject: [PATCH 4/8] test(oneshot): cover unavailable resumed workspace --- tests/hermes_cli/test_oneshot_resume_cwd.py | 73 +++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/hermes_cli/test_oneshot_resume_cwd.py diff --git a/tests/hermes_cli/test_oneshot_resume_cwd.py b/tests/hermes_cli/test_oneshot_resume_cwd.py new file mode 100644 index 000000000000..761f212f57a6 --- /dev/null +++ b/tests/hermes_cli/test_oneshot_resume_cwd.py @@ -0,0 +1,73 @@ +import pytest + + +def test_oneshot_resume_fails_before_reopen_when_recorded_cwd_is_missing(monkeypatch): + import hermes_cli.oneshot as oneshot_mod + + reopened = [] + + class FakeSessionDB: + def get_session(self, session_id): + return {"id": session_id, "cwd": "/recorded/workspace"} + + def resolve_session_by_title(self, _title): + return None + + def resolve_resume_session_id(self, session_id): + return session_id + + def get_resume_conversations(self, _session_id): + return ([{"role": "user", "content": "prior context"}], []) + + def reopen_session(self, session_id): + reopened.append(session_id) + + monkeypatch.setattr(oneshot_mod.os.path, "isdir", lambda _path: False) + + with pytest.raises( + FileNotFoundError, + match="Recorded session working directory is unavailable", + ): + oneshot_mod._load_oneshot_resume( + FakeSessionDB(), + resume_session_id="session-1", + continue_last=False, + restore_resume_cwd=True, + ) + + assert reopened == [] + + +def test_oneshot_resume_allows_explicit_cwd_restore_opt_out(monkeypatch): + import hermes_cli.oneshot as oneshot_mod + + reopened = [] + + class FakeSessionDB: + def get_session(self, session_id): + return {"id": session_id, "cwd": "/recorded/workspace"} + + def resolve_session_by_title(self, _title): + return None + + def resolve_resume_session_id(self, session_id): + return session_id + + def get_resume_conversations(self, _session_id): + return ([{"role": "user", "content": "prior context"}], []) + + def reopen_session(self, session_id): + reopened.append(session_id) + + monkeypatch.setattr(oneshot_mod.os.path, "isdir", lambda _path: False) + + session_id, history = oneshot_mod._load_oneshot_resume( + FakeSessionDB(), + resume_session_id="session-1", + continue_last=False, + restore_resume_cwd=False, + ) + + assert session_id == "session-1" + assert history == [{"role": "user", "content": "prior context"}] + assert reopened == ["session-1"] From e784efcd44d2b78e5de0e278018b78d487a3e0f3 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 11:19:08 +0200 Subject: [PATCH 5/8] fix(oneshot): synchronize terminal workspace on resume --- hermes_cli/oneshot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 11549752ce64..580ddc698325 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -392,6 +392,10 @@ def _load_oneshot_resume( "Failed to restore recorded session working directory: " f"{saved_cwd}" ) from exc + # Prompt construction and file/terminal tools prefer this value. + # Publish it only after chdir succeeds so a failed resume leaves + # the caller's runtime context untouched. + os.environ["TERMINAL_CWD"] = saved_cwd session_db.reopen_session(resolved_session_id) return resolved_session_id, conversation_history From f6e2819b517f4583b4c3e394bec2e8d7ca07b678 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 11:19:46 +0200 Subject: [PATCH 6/8] test(oneshot): cover terminal workspace synchronization --- tests/hermes_cli/test_oneshot_terminal_cwd.py | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 tests/hermes_cli/test_oneshot_terminal_cwd.py diff --git a/tests/hermes_cli/test_oneshot_terminal_cwd.py b/tests/hermes_cli/test_oneshot_terminal_cwd.py new file mode 100644 index 000000000000..9ad09ec7d538 --- /dev/null +++ b/tests/hermes_cli/test_oneshot_terminal_cwd.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import os +import sys +import types +from pathlib import Path + +import pytest + + +def _session_db(workspace: Path, *, events: list[object]): + class FakeSessionDB: + def get_session(self, session_id): + if session_id == "session-1": + return {"id": session_id, "cwd": str(workspace)} + return None + + def resolve_session_by_title(self, _title): + return None + + def resolve_resume_session_id(self, session_id): + return session_id + + def get_resume_conversations(self, _session_id): + return ([{"role": "user", "content": "prior context"}], []) + + def reopen_session(self, session_id): + events.append(("reopen", session_id, os.environ.get("TERMINAL_CWD"))) + + def close(self): + events.append("close") + + return FakeSessionDB() + + +def test_resume_publishes_terminal_cwd_before_session_reopen( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import hermes_cli.oneshot as oneshot + + workspace = tmp_path / "recorded-workspace" + workspace.mkdir() + events: list[object] = [] + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "stale-workspace")) + + session_id, history = oneshot._load_oneshot_resume( + _session_db(workspace, events=events), + resume_session_id="session-1", + continue_last=None, + restore_resume_cwd=True, + ) + + assert session_id == "session-1" + assert history == [{"role": "user", "content": "prior context"}] + assert Path.cwd() == workspace.resolve() + assert os.environ["TERMINAL_CWD"] == str(workspace) + assert events == [("reopen", "session-1", str(workspace))] + + +def test_missing_recorded_workspace_does_not_mutate_terminal_context_or_session( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import hermes_cli.oneshot as oneshot + + events: list[object] = [] + previous = str(tmp_path / "previous-workspace") + monkeypatch.setenv("TERMINAL_CWD", previous) + + with pytest.raises(FileNotFoundError, match="working directory is unavailable"): + oneshot._load_oneshot_resume( + _session_db(tmp_path / "missing", events=events), + resume_session_id="session-1", + continue_last=None, + restore_resume_cwd=True, + ) + + assert os.environ["TERMINAL_CWD"] == previous + assert events == [] + + +def test_failed_chdir_does_not_publish_terminal_context_or_reopen_session( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import hermes_cli.oneshot as oneshot + + workspace = tmp_path / "recorded-workspace" + workspace.mkdir() + events: list[object] = [] + monkeypatch.setenv("TERMINAL_CWD", "/previous") + + def fail_chdir(_path: object) -> None: + raise OSError("blocked") + + monkeypatch.setattr(os, "chdir", fail_chdir) + + with pytest.raises(RuntimeError, match="Failed to restore"): + oneshot._load_oneshot_resume( + _session_db(workspace, events=events), + resume_session_id="session-1", + continue_last=None, + restore_resume_cwd=True, + ) + + assert os.environ["TERMINAL_CWD"] == "/previous" + assert events == [] + + +def test_no_restore_cwd_preserves_explicit_caller_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import hermes_cli.oneshot as oneshot + + workspace = tmp_path / "recorded-workspace" + workspace.mkdir() + events: list[object] = [] + previous = str(tmp_path / "intentional-caller-workspace") + monkeypatch.setenv("TERMINAL_CWD", previous) + original_cwd = Path.cwd() + + oneshot._load_oneshot_resume( + _session_db(workspace, events=events), + resume_session_id="session-1", + continue_last=None, + restore_resume_cwd=False, + ) + + assert Path.cwd() == original_cwd + assert os.environ["TERMINAL_CWD"] == previous + assert events == [("reopen", "session-1", previous)] + + +def test_agent_construction_observes_restored_terminal_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import hermes_cli.oneshot as oneshot + + workspace = tmp_path / "recorded-workspace" + workspace.mkdir() + events: list[object] = [] + observed: list[str | None] = [] + db = _session_db(workspace, events=events) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "stale")) + + class FakeAgent: + def __init__(self, **_kwargs): + observed.append(os.environ.get("TERMINAL_CWD")) + self.suppress_status_output = False + self.stream_delta_callback = object() + self.tool_gen_callback = object() + self._session_messages = [] + + def run_conversation(self, _prompt, **_kwargs): + return {"final_response": "ok"} + + def shutdown_memory_provider(self, _messages=None): + pass + + def close(self): + pass + + monkeypatch.setitem(sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent)) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "test-model", "provider": "openai"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "api_key": "test", + "base_url": "https://example.invalid", + "provider": "openai", + "requested_provider": "openai", + "api_mode": "chat_completions", + "credential_pool": None, + }, + ) + monkeypatch.setattr(oneshot, "_create_session_db_for_oneshot", lambda: db) + monkeypatch.setattr(oneshot, "get_fallback_chain", lambda _cfg: []) + + result = oneshot._run_agent( + "continue", + model="test-model", + provider="openai", + use_config_toolsets=False, + resume_session_id="session-1", + restore_resume_cwd=True, + ) + + assert result == ("ok", {"final_response": "ok"}) + assert observed == [str(workspace)] From 123dc832387770a1ad595c6060ed7473248ccc31 Mon Sep 17 00:00:00 2001 From: SE87H Date: Fri, 31 Jul 2026 13:00:15 +0200 Subject: [PATCH 7/8] test(oneshot): restore process cwd after workspace tests --- tests/hermes_cli/test_oneshot_terminal_cwd.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_oneshot_terminal_cwd.py b/tests/hermes_cli/test_oneshot_terminal_cwd.py index 9ad09ec7d538..8d87d41a43fc 100644 --- a/tests/hermes_cli/test_oneshot_terminal_cwd.py +++ b/tests/hermes_cli/test_oneshot_terminal_cwd.py @@ -8,6 +8,12 @@ import pytest +@pytest.fixture(autouse=True) +def _restore_process_cwd(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep production chdir calls from leaking into later tests.""" + monkeypatch.chdir(Path.cwd()) + + def _session_db(workspace: Path, *, events: list[object]): class FakeSessionDB: def get_session(self, session_id): @@ -192,4 +198,4 @@ def close(self): ) assert result == ("ok", {"final_response": "ok"}) - assert observed == [str(workspace)] + assert observed == [str(workspace)] \ No newline at end of file From 5931f54131ce336a3024bd2b5f844b1162cda30c Mon Sep 17 00:00:00 2001 From: M6R local rebase Date: Tue, 4 Aug 2026 23:08:07 +0200 Subject: [PATCH 8/8] test(oneshot): preserve resume invariants after fresh-main rebase --- tests/hermes_cli/test_oneshot_resume_flow.py | 251 +++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 tests/hermes_cli/test_oneshot_resume_flow.py diff --git a/tests/hermes_cli/test_oneshot_resume_flow.py b/tests/hermes_cli/test_oneshot_resume_flow.py new file mode 100644 index 000000000000..a668bbb069fe --- /dev/null +++ b/tests/hermes_cli/test_oneshot_resume_flow.py @@ -0,0 +1,251 @@ +from argparse import Namespace +import sys +import types + +import pytest + + +def _raise_exit(rc): + raise SystemExit(rc) + + +@pytest.fixture +def main_mod(monkeypatch): + import hermes_cli.main as mod + + monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True) + monkeypatch.setattr(mod, "_oneshot_cleanup_done", False) + return mod + + +def test_run_and_exit_oneshot_forwards_resume_fields(monkeypatch, main_mod): + calls = [] + exits = [] + + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace( + run_oneshot=lambda prompt, **kwargs: calls.append((prompt, kwargs)) or 0 + ), + ) + monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) + + main_mod._run_and_exit_oneshot( + "continue", + resume_session_id="session-1", + continue_last=False, + restore_resume_cwd=False, + ) + + assert calls == [ + ( + "continue", + { + "model": None, + "provider": None, + "toolsets": None, + "usage_file": None, + "resume_session_id": "session-1", + "continue_last": False, + "restore_resume_cwd": False, + }, + ) + ] + assert exits == [0] + + +def test_top_level_oneshot_forwards_default_resume_fields(monkeypatch, main_mod): + captured = {} + import hermes_cli.config as config_mod + + monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--usage-file", "usage.json"]) + monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None)) + monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None)) + monkeypatch.setattr(config_mod, "load_config", lambda: {}) + monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None) + monkeypatch.setitem( + sys.modules, + "agent.shell_hooks", + types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace( + run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0 + ), + ) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", _raise_exit) + + with pytest.raises(SystemExit) as exc: + main_mod.main() + + assert exc.value.code == 0 + assert captured["prompt"] == "hello" + assert captured["usage_file"] == "usage.json" + assert captured["resume_session_id"] is None + assert captured["continue_last"] is None + assert captured["restore_resume_cwd"] is True + + +def test_termux_oneshot_forwards_default_resume_fields(monkeypatch, main_mod): + captured = {} + prepared = [] + + monkeypatch.setenv("TERMUX_VERSION", "1") + monkeypatch.delenv("HERMES_TUI", raising=False) + monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--usage-file", "usage.json"]) + monkeypatch.setattr(main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command)) + monkeypatch.setitem( + sys.modules, + "hermes_cli.oneshot", + types.SimpleNamespace( + run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0 + ), + ) + monkeypatch.setattr(main_mod, "_exit_after_oneshot", _raise_exit) + + with pytest.raises(SystemExit) as exc: + main_mod._try_termux_fast_cli_launch() + + assert exc.value.code == 0 + assert prepared == [None] + assert captured["prompt"] == "hello" + assert captured["resume_session_id"] is None + assert captured["continue_last"] is None + assert captured["restore_resume_cwd"] is True + + +def test_run_agent_reuses_tip_filters_meta_reopens_and_passes_history(monkeypatch): + import hermes_cli.oneshot as oneshot_mod + + initialized = [] + run_calls = [] + reopened = [] + closed = [] + history = [ + {"role": "session_meta", "content": "internal"}, + {"role": "user", "content": "before"}, + {"role": "assistant", "content": "context"}, + ] + + class FakeAgent: + def __init__(self, **kwargs): + initialized.append(kwargs) + self.suppress_status_output = False + self.stream_delta_callback = object() + self.tool_gen_callback = object() + + def run_conversation(self, prompt, **kwargs): + run_calls.append((prompt, kwargs)) + return {"final_response": "continued"} + + def shutdown_memory_provider(self, messages=None): + pass + + def close(self): + pass + + class FakeSessionDB: + def get_session(self, session_id): + if session_id in {"root", "tip"}: + return {"id": session_id, "cwd": "/missing"} + return None + + def resolve_session_by_title(self, _title): + return None + + def resolve_resume_session_id(self, session_id): + assert session_id == "root" + return "tip" + + def get_resume_conversations(self, session_id): + assert session_id == "tip" + return list(history), list(history) + + def reopen_session(self, session_id): + reopened.append(session_id) + + def close(self): + closed.append(True) + + monkeypatch.setitem(sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent)) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **_kwargs: { + "api_key": "key", + "base_url": "https://example.invalid", + "provider": "openai", + "api_mode": "chat_completions", + "credential_pool": None, + }, + ) + monkeypatch.setattr(oneshot_mod, "_create_session_db_for_oneshot", lambda: FakeSessionDB()) + + assert oneshot_mod._run_agent( + "continue", + model="gpt-test", + provider="openai", + use_config_toolsets=False, + resume_session_id="root", + restore_resume_cwd=False, + ) == ("continued", {"final_response": "continued"}) + assert initialized[0]["session_id"] == "tip" + assert reopened == ["tip"] + assert closed == [True] + assert run_calls == [ + ( + "continue", + { + "conversation_history": [ + {"role": "user", "content": "before"}, + {"role": "assistant", "content": "context"}, + ] + }, + ) + ] + + +def test_continue_prefers_current_workspace_session(monkeypatch, main_mod): + calls = [] + + class FakeDB: + def search_sessions(self, **kwargs): + calls.append(kwargs) + return [{"id": "workspace-session"}] if kwargs.get("workspace_key") == "/repo" else [] + + def close(self): + pass + + monkeypatch.setitem(sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: FakeDB())) + monkeypatch.setattr(main_mod, "_resolve_workspace_key", lambda: "/repo") + + assert main_mod._resolve_last_session("cli") == "workspace-session" + assert calls == [{"source": "cli", "limit": 1, "workspace_key": "/repo"}] + + +def test_continue_uses_global_mru_only_without_workspace_session(monkeypatch, main_mod): + calls = [] + + class FakeDB: + def search_sessions(self, **kwargs): + calls.append(kwargs) + return [] if kwargs.get("workspace_key") else [{"id": "global-session"}] + + def close(self): + pass + + monkeypatch.setitem(sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: FakeDB())) + monkeypatch.setattr(main_mod, "_resolve_workspace_key", lambda: "/repo") + + assert main_mod._resolve_last_session("cli") == "global-session" + assert calls == [ + {"source": "cli", "limit": 1, "workspace_key": "/repo"}, + {"source": "cli", "limit": 1}, + ]