Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading