Skip to content
2 changes: 2 additions & 0 deletions contributors/emails/aeon-core@avaeon.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SE87H
# Internal validation of upstream PR #74397
2 changes: 2 additions & 0 deletions contributors/emails/julienyezniguian@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SE87H
# Internal validation of upstream PR #74397
12 changes: 12 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,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
Expand All @@ -181,6 +184,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
Expand Down Expand Up @@ -10528,6 +10534,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:
Expand Down Expand Up @@ -12131,6 +12140,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
Expand Down
128 changes: 127 additions & 1 deletion hermes_cli/oneshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import logging
import os
import subprocess
import sys
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
Expand Down Expand Up @@ -173,6 +174,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.

Expand All @@ -187,6 +191,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.
"""
Expand Down Expand Up @@ -248,6 +257,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
Expand Down Expand Up @@ -310,12 +322,115 @@ 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 = []
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"]

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"
]

if restore_resume_cwd:
saved_cwd = str(session_meta.get("cwd") or "").strip()
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
# 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


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,
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)``."""
Expand Down Expand Up @@ -402,6 +517,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.
Expand All @@ -418,6 +540,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
Expand All @@ -440,7 +563,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,
Expand Down
73 changes: 73 additions & 0 deletions tests/hermes_cli/test_oneshot_resume_cwd.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading