diff --git a/CHANGELOG.md b/CHANGELOG.md index 349b264..3732cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased + +### Added +- **`devorch --continue` / `-c`**: continue the latest conversation started in this directory. Sessions now record + their directory (older sessions have none and are not matched by `--continue`). +- **`devorch --resume` with no ID** opens a session picker (this directory first, with an option for all); **`/resume`** + switches session inside the REPL. +- **`devorch --version` / `-V`**. +- **`devorch doctor`** (and `/doctor`): a read-only health report (providers, keys, permissions, project trust, + terminal). It never prints key values, and it warns when `edit`/`filesystem` are pre-approved. +- `devorch sessions list --here`, and a Directory column in session listings. + +### Fixed +- Messages in a resumed session could come back out of order when saved within the same second; they are now loaded + in the order they were saved. +- A stray `pytest` run no longer overwrites your real `~/.devorch` (it replaced saved permissions with + `edit: allow` / `filesystem: allow`); each test also starts with an empty home. + ## 0.5.0 A large feature release: hooks, checkpoints, project settings, image input, streaming, prompt caching, diff --git a/README.md b/README.md index 71f70d0..a6bca60 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,11 @@ cd DevOrch && pip install -e . devorch # Interactive setup on first run devorch -p openai # Use a specific provider devorch -p local # Use Ollama (local models) -devorch --resume abc123 # Resume a previous session +devorch --continue # Continue the latest conversation started in this directory (-c) +devorch --resume # Pick a previous session from a list (this directory's first) +devorch --resume abc123 # Resume a specific session by ID +devorch --version # Print the version (-V) +devorch doctor # Check your setup: providers, keys, permissions, project trust, terminal # Non-interactive (scripting / CI) devorch ask "explain this project" @@ -262,6 +266,15 @@ pricing: cache_write: 3.75 # optional, defaults to the input price ``` +### Continuing and resuming sessions + +Every session remembers the directory it was started in. `devorch --continue` (`-c`) reopens the most recent +non-empty conversation from the current directory; if there is none it tells you and starts a new one. A bare +`devorch --resume` shows a picker of this directory's sessions (labelled by what you first asked) with an option to +widen it to every directory, and `/resume` does the same without leaving DevOrch. Sessions created by earlier versions have +no recorded directory, so `--continue` cannot find them, but they still appear in the picker's "all directories" +view and can be resumed by ID. `devorch sessions list --here` lists only this directory's sessions. + ### Prompt Shortcuts | Key | Action | @@ -487,7 +500,9 @@ overrides a project's `default_mode`. In the REPL, `/auto` and `/ask` switch the | `/mcp stop ` | Disconnect a server and remove its tools | | `/config` | Show configuration settings | | `/permissions` | Show permissions; change them with `/permissions allow\|deny [--session] `, `set `, `reset` | -| `/sessions` | `list [n]`, `show `, `delete ` (resume with `devorch --resume `) | +| `/sessions` | `list [n] [here]`, `show `, `delete ` | +| `/resume` | Switch to another saved session (`/resume` opens a picker, `/resume ` goes straight to it) | +| `/doctor` | Check your setup (same as `devorch doctor`) | | `/hooks` | Show active hooks and whether project hooks are trusted | | `/trust` / `/untrust` | Trust (or stop trusting) this project's `.devorch` settings and hooks | | `/cost` | Estimated session cost per model | diff --git a/cli/constants.py b/cli/constants.py index 612172a..fceb166 100644 --- a/cli/constants.py +++ b/cli/constants.py @@ -45,7 +45,9 @@ "/session": "Show current session ID, model, and message count", "/config": "Show configuration settings and provider status", "/permissions": "Show or change permissions (/permissions allow|deny|set|reset)", - "/sessions": "List, show or delete chat sessions (/sessions list|show |delete )", + "/sessions": "List, show or delete chat sessions (/sessions list [here]|show |delete )", + "/resume": "Switch to another saved session (/resume or /resume )", + "/doctor": "Check your setup: providers, keys, permissions, project trust, terminal", "/hooks": "Show active hooks and whether project hooks are trusted", "/trust": "Trust this project's .devorch settings and hooks (MCP, allow rules, hooks)", "/untrust": "Revoke trust for this project's .devorch files", diff --git a/cli/main.py b/cli/main.py index 9e507a1..6c5bf73 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,4 +1,5 @@ import os +import sys import time from pathlib import Path @@ -6,8 +7,10 @@ import typer from prompt_toolkit import prompt as pt_prompt from prompt_toolkit.history import FileHistory +from rich.markup import escape from rich.panel import Panel from rich.table import Table +from rich.text import Text from cli.commands._shared import ( # noqa: E402 SKIP_PERMISSIONS_HELP, @@ -26,6 +29,7 @@ PROMPT_STYLE, QUESTIONARY_STYLE, SLASH_COMMANDS, + VERSION, SlashCommandCompleter, print_banner, ) @@ -42,13 +46,17 @@ ) from cli.prompt_ui import prompt_message as build_prompt_message from cli.repl_commands import ( # noqa: E402 + PICK_SESSION, handle_hooks, handle_permissions, + handle_resume, handle_sessions, handle_trust, handle_untrust, + last_exchange_preview, print_session_details, print_sessions_table, + resolve_startup_session, ) from config.permissions import ( PERMISSIONS_FILE, @@ -683,6 +691,7 @@ def start_repl( show_banner: bool = True, mode: str | None = None, skip_permissions: bool = False, + continue_session: bool = False, ): """Start the interactive REPL session.""" if show_banner: @@ -698,7 +707,14 @@ def start_repl( context_summary = None - # Handle session resumption + # Handle session resumption: --resume , a bare --resume (picker), or --continue + choice = resolve_startup_session(session_manager, resume, continue_session) + if choice.cancelled: + print_info("No session chosen.") + raise typer.Exit(0) + if choice.notice: + print_info(choice.notice) + resume = choice.session_id if resume: try: session_info, messages = session_manager.load_session(resume) @@ -708,6 +724,8 @@ def start_repl( print_success(f"Resumed session: {resume}") print_info(f"Provider: {provider} | Model: {model} | Messages: {len(messages)}") + for line in last_exchange_preview(messages): + console.print(f" [dim]{escape(line)}[/dim]") if context_summary: print_info("Session has context from previous conversation") @@ -960,6 +978,7 @@ def prompt_gutter(width, line_number, wrap_count): "/tokens", "/cost", "/sessions", + "/resume", "/copy", "/paste", "/undo", @@ -976,6 +995,7 @@ def prompt_gutter(width, line_number, wrap_count): "/tasks", "/config", "/permissions", + "/doctor", "/hooks", "/trust", "/untrust", @@ -1200,6 +1220,14 @@ def prompt_gutter(width, line_number, wrap_count): ) continue + elif cmd == "resume": + handle_resume(cmd_arg, session_manager, agent, executor) + continue + + elif cmd == "doctor": + render_doctor() + continue + elif cmd == "hooks": handle_hooks() continue @@ -2021,16 +2049,88 @@ def prompt_gutter(width, line_number, wrap_count): console.print("[dim] - Make sure Ollama is running: ollama serve[/dim]") +def _version_callback(value: bool): + if value: + console.print(f"devorch {VERSION}", highlight=False) + raise typer.Exit() + + +def normalize_resume_argv(argv: list[str]) -> list[str]: + """Turn a bare ``--resume`` / ``-r`` (no session id) into ``--resume __pick__``. + + Typer cannot express an option with an optional value: a bare ``--resume`` errors, and + ``--resume -c`` would swallow ``-c`` as the id. A value-less ``--resume`` (at the end, or followed + by another option) therefore becomes the internal picker marker. + """ + out: list[str] = [] + for i, arg in enumerate(argv): + out.append(arg) + if arg in ("--resume", "-r") and (i + 1 == len(argv) or argv[i + 1].startswith("-")): + out.append(PICK_SESSION) + return out + + +def render_doctor() -> int: + """Print the doctor report and return the process exit code (1 if any check failed).""" + from core.doctor import FAIL, INFO, OK, WARN, exit_code, run_checks + + rows = run_checks() + icons = {OK: ("✓", "green"), WARN: ("!", "yellow"), FAIL: ("✗", "red"), INFO: ("·", "dim")} + table = Table(title="DevOrch doctor", show_header=False, box=None, pad_edge=False) + table.add_column(width=2) + table.add_column(style="bold", no_wrap=True) + table.add_column(overflow="fold") # fold, don't cut: full paths matter in a bug report + for r in rows: + icon, color = icons[r.status] + table.add_row(Text(icon, style=color), Text(r.name), Text(r.detail)) + if r.hint: + table.add_row("", "", Text(f"→ {r.hint}", style="dim")) + console.print(table) + summary = {s: sum(1 for r in rows if r.status == s) for s in (OK, WARN, FAIL)} + console.print( + f"\n[green]{summary[OK]} ok[/green], [yellow]{summary[WARN]} warnings[/yellow], " + f"[red]{summary[FAIL]} problems[/red]\n" + ) + return exit_code(rows) + + +@app.command("doctor") +def doctor_command(): + """ + Check your setup (providers, keys, permissions, project trust, terminal) for problems. + """ + raise typer.Exit(render_doctor()) + + @app.callback() def main_callback( ctx: typer.Context, provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"), model: str = typer.Option(None, "--model", "-m", help="Model name"), - resume: str = typer.Option(None, "--resume", "-r", help="Resume session by ID"), + resume: str = typer.Option( + None, + "--resume", + "-r", + help="Resume a session by ID; use --resume on its own to pick from a list", + ), + continue_session: bool = typer.Option( + False, + "--continue", + "-c", + help="Continue the most recent conversation started in this directory", + ), mode: str = typer.Option(None, "--mode", help="Start in this mode: ask, auto, plan"), skip_permissions: bool = typer.Option( False, "--dangerously-skip-permissions", help=SKIP_PERMISSIONS_HELP ), + version: bool = typer.Option( + False, + "--version", + "-V", + callback=_version_callback, + is_eager=True, + help="Show the DevOrch version and exit", + ), ): """ DevOrch - Your AI Coding Assistant @@ -2060,6 +2160,7 @@ def main_callback( resume=resume, mode=mode, skip_permissions=skip_permissions, + continue_session=continue_session, ) @@ -2067,7 +2168,18 @@ def main_callback( def chat( provider: str = typer.Option(None, "--provider", "-p", help="LLM Provider"), model: str = typer.Option(None, "--model", "-m", help="Model name"), - resume: str = typer.Option(None, "--resume", "-r", help="Resume session by ID"), + resume: str = typer.Option( + None, + "--resume", + "-r", + help="Resume a session by ID; use --resume on its own to pick from a list", + ), + continue_session: bool = typer.Option( + False, + "--continue", + "-c", + help="Continue the most recent conversation started in this directory", + ), message_limit: int = typer.Option( DEFAULT_MESSAGE_LIMIT, "--limit", "-l", help="Messages before auto-summarization" ), @@ -2095,6 +2207,7 @@ def chat( message_limit=message_limit, mode=mode, skip_permissions=skip_permissions, + continue_session=continue_session, ) @@ -2352,11 +2465,12 @@ def skills_list(): @sessions_app.command("list") def sessions_list( limit: int = typer.Option(20, "--limit", "-n", help="Number of sessions to show"), + here: bool = typer.Option(False, "--here", help="Only sessions started in this directory"), ): """ List recent chat sessions. """ - print_sessions_table(SessionManager(), limit) + print_sessions_table(SessionManager(), limit, here=here) console.print("\n[dim]Resume a session: devorch --resume [/dim]") @@ -2552,7 +2666,7 @@ def init_project( def main(): - app() + app(args=normalize_resume_argv(sys.argv[1:])) if __name__ == "__main__": diff --git a/cli/repl_commands.py b/cli/repl_commands.py index 7fc4eb6..5fcfaa2 100644 --- a/cli/repl_commands.py +++ b/cli/repl_commands.py @@ -4,10 +4,13 @@ without a running REPL. The CLI commands call the same functions where they share logic. """ +import os import shlex from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path +from rich.markup import escape as rich_escape from rich.table import Table from config.permissions import PermissionLevel, Permissions @@ -23,6 +26,11 @@ console = get_console() Confirm = Callable[[str], bool] +# select(message, [(label, value), ...]) -> chosen value, or None if cancelled +Select = Callable[[str, list[tuple[str, str]]], str | None] + +PICK_SESSION = "__pick__" # internal marker: a bare `--resume` means "let me choose" +ALL_DIRECTORIES = "__all__" PERMISSIONS_USAGE = ( "Usage: /permissions [list]\n" @@ -119,8 +127,14 @@ def handle_permissions(arg: str | None, perms: Permissions, confirm: Confirm | N # ── sessions ──────────────────────────────────────────────────────────────── -def print_sessions_table(session_manager, limit: int = 20, current_id: str | None = None) -> None: - sessions = session_manager.list_sessions(limit=limit) +def print_sessions_table( + session_manager, + limit: int = 20, + current_id: str | None = None, + here: bool = False, +) -> None: + """List sessions (all, or only those started in the current directory with ``here``).""" + sessions = session_manager.list_sessions(limit=limit, cwd=os.getcwd() if here else None) if not sessions: print_info("No sessions found.") return @@ -129,6 +143,7 @@ def print_sessions_table(session_manager, limit: int = 20, current_id: str | Non table.add_column("Name", style="white") table.add_column("Provider", style="green") table.add_column("Model", style="blue") + table.add_column("Directory", style="dim") table.add_column("Msgs", justify="right") table.add_column("Parent", style="dim") table.add_column("Updated", style="dim") @@ -139,6 +154,7 @@ def print_sessions_table(session_manager, limit: int = 20, current_id: str | Non (session["name"] or "-")[:20], session["provider"], session["model"], + _short_dir(session.get("cwd")), str(session["message_count"]), session.get("parent_session_id") or "-", str(session["updated_at"])[:16], @@ -172,14 +188,19 @@ def handle_sessions( tokens = (arg or "").split() action = tokens[0].lower() if tokens else "list" if action == "list": + extras = [t.lower() for t in tokens[1:]] + here = "here" in extras + counts = [t for t in extras if t != "here"] limit = 20 - if len(tokens) > 1: - if not tokens[1].isdigit(): - print_error("Usage: /sessions list [count]") + if counts: + if len(counts) > 1 or not counts[0].isdigit(): + print_error("Usage: /sessions list [count] [here]") return - limit = max(1, int(tokens[1])) - print_sessions_table(session_manager, limit, current_id) - console.print("[dim] Resume a session with: devorch --resume [/dim]\n") + limit = max(1, int(counts[0])) + print_sessions_table(session_manager, limit, current_id, here=here) + console.print( + "[dim] Resume one with /resume, or devorch --continue / --resume [/dim]\n" + ) elif action == "show" and len(tokens) == 2: print_session_details(session_manager, tokens[1]) elif action == "delete" and len(tokens) == 2: @@ -198,7 +219,7 @@ def handle_sessions( else: print_error("Failed to delete session.") else: - print_error("Usage: /sessions [list [count]] | show | delete ") + print_error("Usage: /sessions [list [count] [here]] | show | delete ") # ── hooks / trust ─────────────────────────────────────────────────────────── @@ -257,3 +278,176 @@ def trust_status(start: Path | None = None) -> list[tuple[Path, bool]]: return [ (root, is_trusted(root)) for root in dict.fromkeys(r for r, _ in project_trust_files(start)) ] + + +# ── resuming sessions ─────────────────────────────────────────────────────── + + +def questionary_select(message: str, choices: list[tuple[str, str]]) -> str | None: + """Default chooser: an arrow-key list. Returns None if the user cancels.""" + import questionary + + return questionary.select( + message, + choices=[questionary.Choice(title=label, value=value) for label, value in choices], + ).ask() + + +def _short_dir(path: str | None) -> str: + if not path: + return "unknown directory" + home = os.path.normcase(os.path.realpath(Path.home())) + p = path + if p == home: + return "~" + if p.startswith(home + os.sep): + p = "~" + p[len(home) :] + parts = p.replace("\\", "/").split("/") + return "/".join(parts[-2:]) if len(parts) > 2 else p + + +def session_label(session_manager, session: dict, show_dir: bool) -> str: + title = ( + session_manager.first_user_message(session["id"], limit=60) or session.get("name") or "-" + ) + when = str(session.get("updated_at", ""))[:16] + where = f" [{_short_dir(session.get('cwd'))}]" if show_dir else "" + return f"{when} {title} ({session['provider']}/{session['model']}, {session['message_count']} msgs){where}" + + +def pick_session( + session_manager, + cwd: str | os.PathLike | None = None, + select: Select | None = None, + limit: int = 20, +) -> str | None: + """Let the user choose a session: this directory's first, with a way to see all of them. + + Returns the session id, or None if there is nothing to choose or the user cancelled. + """ + select = select or questionary_select + here = cwd if cwd is not None else os.getcwd() + total_here = session_manager.count_sessions(cwd=here) + total_all = session_manager.count_sessions() + if total_all == 0: + print_info("No saved sessions to resume.") + return None + + scope_all = total_here == 0 + if scope_all: + print_info("No sessions in this directory yet; showing sessions from all directories.") + while True: + sessions = session_manager.list_sessions( + limit=limit, cwd=None if scope_all else here, include_empty=False + ) + choices = [ + (session_label(session_manager, s, show_dir=scope_all), s["id"]) for s in sessions + ] + if not scope_all and total_all > total_here: + choices.append(("Show sessions from all directories…", ALL_DIRECTORIES)) + title = "Resume which session?" + ("" if scope_all else " (this directory)") + picked = select(title, choices) + if picked == ALL_DIRECTORIES: + scope_all = True + continue + return picked + + +@dataclass +class StartupChoice: + session_id: str | None = None # None = start a new session + notice: str | None = None + cancelled: bool = False # the user backed out of the picker + + +def resolve_startup_session( + session_manager, + resume: str | None, + continue_session: bool, + cwd: str | os.PathLike | None = None, + select: Select | None = None, +) -> StartupChoice: + """Decide which session the REPL starts in from ``--resume`` / ``--continue``. + + An explicit session id wins over ``--continue``; a bare ``--resume`` opens the picker. + """ + if resume == PICK_SESSION: + picked = pick_session(session_manager, cwd, select) + if picked is None: + has_any = bool(session_manager.list_sessions(limit=1, include_empty=False)) + return StartupChoice( + cancelled=has_any, notice=None if has_any else "Starting a new session." + ) + return StartupChoice(session_id=picked) + if resume: + return StartupChoice(session_id=resume) + if continue_session: + sid = session_manager.latest_session_id(cwd) + if sid is None: + return StartupChoice( + notice="No earlier conversation in this directory; starting a new session." + ) + return StartupChoice(session_id=sid) + return StartupChoice() + + +def last_exchange_preview(messages, limit: int = 160) -> list[str]: + """One line each for the last thing you said and the last reply, to show where you left off.""" + + def flat(text: str) -> str: + text = " ".join(text.split()).split(" Attached context:")[0] + return text if len(text) <= limit else text[: limit - 1] + "…" + + def last(role: str) -> str | None: + for m in reversed(messages): + if m.role == role and m.content and not m.content.startswith("[The user interrupted"): + if m.role == "assistant" and m.content == "Calling tool...": + continue + return flat(m.content) + return None + + lines = [] + if (u := last("user")) is not None: + lines.append(f"You: {u}") + if (a := last("assistant")) is not None: + lines.append(f"DevOrch: {a}") + return lines + + +def handle_resume( + arg: str | None, + session_manager, + agent, + executor, + select: Select | None = None, +) -> bool: + """``/resume [id]``: switch this REPL to another saved session. True if it switched.""" + from core.checkpoints import CheckpointManager + from core.tasks import reset_task_manager + + target = (arg or "").strip() or pick_session(session_manager, select=select) + if not target: + return False + if target == session_manager.current_session_id: + print_info("Already in that session.") + return False + try: + info, messages = session_manager.load_session(target) + except ValueError as e: + print_error(str(e)) + return False + + agent.set_history(messages) + agent.set_context_summary(info.get("summary")) + executor.checkpoints = CheckpointManager() # snapshots belong to the conversation we left + reset_task_manager() + + print_success(f"Resumed session {target} ({len(messages)} messages)") + if (info["provider"], info["model"]) != (agent.provider.name, agent.provider.model): + print_info( + f"It used {info['provider']}/{info['model']}; still using " + f"{agent.provider.name}/{agent.provider.model}. Switch with /model or /provider." + ) + for line in last_exchange_preview(messages): + console.print(f" [dim]{rich_escape(line)}[/dim]") + return True diff --git a/core/agent.py b/core/agent.py index 150909b..e98d26f 100644 --- a/core/agent.py +++ b/core/agent.py @@ -83,7 +83,7 @@ def set_history(self, messages: list[Message]): """Set the conversation history (used when resuming a session).""" self.history = messages - def set_context_summary(self, summary: str): + def set_context_summary(self, summary: str | None): """Set context summary from a previous session.""" self._context_summary = summary diff --git a/core/doctor.py b/core/doctor.py new file mode 100644 index 0000000..c1b58b3 --- /dev/null +++ b/core/doctor.py @@ -0,0 +1,291 @@ +"""`devorch doctor`: a read-only health report you can paste into a bug report. + +Each check returns one or more ``Check`` rows and never prints a secret (API keys are only reported as +"set" or "missing"). A check that raises becomes a failed row instead of crashing the report. +""" + +import locale +import os +import platform +import shutil +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +OK, WARN, FAIL, INFO = "ok", "warn", "fail", "info" +KEYLESS_PROVIDERS = {"local", "lmstudio"} # run locally, no API key needed + + +@dataclass +class Check: + name: str + status: str # ok | warn | fail | info + detail: str + hint: str = "" + + +def check_runtime() -> list[Check]: + from cli.constants import VERSION + + return [ + Check("DevOrch", INFO, f"v{VERSION}"), + Check( + "Python", + OK if sys.version_info >= (3, 10) else FAIL, + f"{platform.python_version()} on {platform.system()} {platform.release()} " + f"({platform.machine()})", + "" if sys.version_info >= (3, 10) else "DevOrch needs Python 3.10 or newer", + ), + ] + + +def check_config_dir() -> list[Check]: + from config.settings import CONFIG_DIR, CONFIG_FILE + + if not CONFIG_DIR.exists(): + return [Check("Config folder", INFO, f"{CONFIG_DIR} (created on first run)")] + writable = os.access(CONFIG_DIR, os.W_OK) + return [ + Check( + "Config folder", + OK if writable else FAIL, + f"{CONFIG_DIR}" + ("" if writable else " is not writable"), + "" if writable else "DevOrch cannot save settings, sessions or memory there", + ), + Check("Config file", OK if CONFIG_FILE.exists() else INFO, str(CONFIG_FILE)), + ] + + +def check_providers(settings) -> list[Check]: + configured, missing_default = [], None + for name, cfg in settings.providers.items(): + if name in KEYLESS_PROVIDERS: + continue + if cfg.api_key: + configured.append(f"{name} ({'keychain' if cfg.key_encrypted else 'env/config'})") + default = settings.default_provider + rows = [ + Check( + "API keys", + OK if configured else WARN, + ", ".join(configured) if configured else "none found", + "" if configured else "run `devorch set-key ` or set e.g. OPENAI_API_KEY", + ) + ] + if default not in KEYLESS_PROVIDERS and not ( + settings.providers.get(default) and settings.providers[default].api_key + ): + missing_default = default + model = settings.get_default_model(default) + rows.append( + Check( + "Default provider", + FAIL if missing_default else OK, + f"{default}" + + (f" / {model}" if model else "") + + (" has no API key" if missing_default else ""), + f"run `devorch set-key {default}` or pick another with `devorch -p `" + if missing_default + else "", + ) + ) + return rows + + +def check_keyring() -> list[Check]: + from config.settings import keyring_available + + ok = keyring_available() + return [ + Check( + "OS keychain", + OK if ok else WARN, + "available" if ok else "not available", + "" if ok else "API keys can only come from environment variables on this machine", + ) + ] + + +def check_git(cwd: Path) -> list[Check]: + exe = shutil.which("git") + if not exe: + return [Check("git", WARN, "not installed", "the `git` tool and branch display need it")] + try: + res = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=cwd, + capture_output=True, + text=True, + timeout=3, + ) + branch = res.stdout.strip() if res.returncode == 0 else None + except (OSError, subprocess.SubprocessError): + branch = None + return [ + Check( + "git", OK, f"{exe}" + (f" (branch: {branch})" if branch else " (not in a repository)") + ) + ] + + +def check_clipboard() -> list[Check]: + for tool in ("pbcopy", "wl-copy", "xclip", "xsel", "clip"): + if shutil.which(tool): + return [Check("Clipboard", OK, f"{tool} (used by /copy)")] + return [ + Check( + "Clipboard", + INFO, + "no clipboard tool found", + "/copy needs xclip, xsel or wl-copy on Linux", + ) + ] + + +def check_images() -> list[Check]: + try: + import PIL # noqa: F401 + + return [Check("Image downscaling", OK, "Pillow installed")] + except ImportError: + return [ + Check( + "Image downscaling", + INFO, + "Pillow not installed", + 'optional: `pip install "devorch[images]"` shrinks large screenshots before sending', + ) + ] + + +def check_project(cwd: Path) -> list[Check]: + from config.project_settings import is_trusted, project_trust_files + + rows = [] + found = [n for n in ("DEVORCH.md", "CLAUDE.md", "AGENTS.md") if (cwd / n).is_file()] + rows.append( + Check( + "Project instructions", + OK if found else INFO, + ", ".join(found) if found else "none (create one with `devorch init`)", + ) + ) + pairs = project_trust_files(cwd) + if not pairs: + rows.append( + Check("Project settings/hooks", INFO, "no .devorch/settings.yaml or hooks.yaml") + ) + for root in dict.fromkeys(r for r, _ in pairs): + trusted = is_trusted(root) + rows.append( + Check( + "Project settings/hooks", + OK if trusted else WARN, + f"{root}: {'trusted' if trusted else 'NOT trusted (its MCP servers, allow rules, auto mode and hooks are ignored)'}", + "" if trusted else "review the files, then run `devorch trust`", + ) + ) + return rows + + +def check_permissions() -> list[Check]: + from config.permissions import PermissionLevel, Permissions + + perms = Permissions.load(include_project=False) + risky = [ + name + for name in ("shell", "edit", "filesystem", "git") + if name in perms.tools and perms.tools[name].level == PermissionLevel.ALLOW + ] + if risky: + return [ + Check( + "Permissions", + WARN, + f"{', '.join(risky)} set to allow: DevOrch will not ask before using them", + "intended? if not, run `devorch permissions reset`", + ) + ] + return [ + Check("Permissions", OK, "file edits and commands ask first (unless you choose AUTO mode)") + ] + + +def check_mcp(settings) -> list[Check]: + n = len(settings.mcp_servers) + return [ + Check( + "MCP servers", + INFO, + f"{n} configured" + (f": {', '.join(settings.mcp_servers)}" if n else ""), + ) + ] + + +def check_sessions() -> list[Check]: + from core.sessions import DB_PATH, SessionManager + + if not DB_PATH.exists(): + return [Check("Sessions", INFO, "no session database yet")] + sm = SessionManager() + return [Check("Sessions", OK, f"{sm.count_sessions(include_empty=True)} saved ({DB_PATH})")] + + +def check_terminal() -> list[Check]: + enc = ( + getattr(sys.stdout, "encoding", None) or locale.getpreferredencoding(False) or "" + ).lower() + utf8 = "utf" in enc + cols = shutil.get_terminal_size((80, 24)).columns + color = "off (NO_COLOR set)" if os.environ.get("NO_COLOR") else "on" + return [ + Check( + "Terminal", + OK if sys.stdout.isatty() else INFO, + f"{'interactive' if sys.stdout.isatty() else 'not a terminal'}, {cols} columns, " + f"TERM={os.environ.get('TERM', '-')}, colour {color}", + ), + Check( + "Text encoding", + OK if utf8 else WARN, + enc or "unknown", + "" + if utf8 + else "box-drawing characters and symbols may render as garbage; use a UTF-8 terminal", + ), + ] + + +def run_checks(cwd: Path | None = None, settings=None) -> list[Check]: + """Run every check; a check that raises becomes a failed row.""" + from config.settings import Settings + + cwd = cwd or Path.cwd() + settings = settings or Settings.load() + plan: list[tuple[str, Callable[[], list[Check]]]] = [ + ("Runtime", check_runtime), + ("Config", check_config_dir), + ("Providers", lambda: check_providers(settings)), + ("Keychain", check_keyring), + ("Permissions", check_permissions), + ("Project", lambda: check_project(cwd)), + ("MCP", lambda: check_mcp(settings)), + ("Sessions", check_sessions), + ("git", lambda: check_git(cwd)), + ("Clipboard", check_clipboard), + ("Images", check_images), + ("Terminal", check_terminal), + ] + rows: list[Check] = [] + for label, fn in plan: + try: + rows.extend(fn()) + except Exception as e: # a broken check must not hide the rest of the report + rows.append(Check(label, FAIL, f"check crashed: {type(e).__name__}: {e}")) + return rows + + +def exit_code(rows: list[Check]) -> int: + return 1 if any(r.status == FAIL for r in rows) else 0 diff --git a/core/sessions.py b/core/sessions.py index 519a70c..9b7f760 100644 --- a/core/sessions.py +++ b/core/sessions.py @@ -1,4 +1,5 @@ import json +import os import sqlite3 import uuid from datetime import datetime @@ -14,6 +15,15 @@ DEFAULT_TOKEN_ESTIMATE_LIMIT = 100000 # Rough token estimate +def normalize_dir(path: str | os.PathLike | None = None) -> str: + """Canonical form of a directory for matching sessions to where they were started. + + Resolves symlinks and, on Windows, case, so ``/proj`` and a symlink to it (or ``C:\\Proj`` and + ``c:\\proj``) count as the same place. + """ + return os.path.normcase(os.path.realpath(path if path is not None else os.getcwd())) + + class SessionManager: """Manages chat session persistence using SQLite.""" @@ -69,6 +79,13 @@ def _ensure_db(self): except sqlite3.OperationalError: pass # Column already exists + # Remember the directory a session was started in, so `--continue` can find the latest + # session for the current project. Older sessions have no directory (NULL). + try: + cursor.execute("ALTER TABLE sessions ADD COLUMN cwd TEXT") + except sqlite3.OperationalError: + pass # Column already exists + conn.commit() conn.close() @@ -79,8 +96,9 @@ def create_session( name: str | None = None, parent_session_id: str | None = None, summary: str | None = None, + cwd: str | os.PathLike | None = None, ) -> str: - """Create a new session and return its ID.""" + """Create a new session (recording its directory) and return its ID.""" session_id = str(uuid.uuid4())[:8] if not name: name = f"Session {datetime.now().strftime('%Y-%m-%d %H:%M')}" @@ -89,9 +107,9 @@ def create_session( cursor = conn.cursor() cursor.execute( - """INSERT INTO sessions (id, name, provider, model, parent_session_id, summary) - VALUES (?, ?, ?, ?, ?, ?)""", - (session_id, name, provider, model, parent_session_id, summary), + """INSERT INTO sessions (id, name, provider, model, parent_session_id, summary, cwd) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (session_id, name, provider, model, parent_session_id, summary, normalize_dir(cwd)), ) conn.commit() @@ -163,9 +181,7 @@ def load_session(self, session_id: str) -> tuple[dict, list[Message]]: session_info = dict(session_row) # Get messages - cursor.execute( - "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at", (session_id,) - ) + cursor.execute("SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,)) message_rows = cursor.fetchall() messages = [] @@ -221,20 +237,37 @@ def get_session_chain(self, session_id: str) -> list[dict]: conn.close() return chain - def list_sessions(self, limit: int = 20) -> list[dict]: - """List recent sessions.""" + def list_sessions( + self, + limit: int = 20, + cwd: str | os.PathLike | None = None, + include_empty: bool = True, + ) -> list[dict]: + """List recent sessions, newest first. + + ``cwd`` limits the list to sessions started in that directory; ``include_empty=False`` + hides sessions with no messages (DevOrch creates one at startup even if nothing is typed). + """ conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cursor = conn.cursor() + where, params = "", [] + if cwd is not None: + where = "WHERE s.cwd = ?" + params.append(normalize_dir(cwd)) + having = "" if include_empty else "HAVING COUNT(m.id) > 0" + cursor.execute( - """SELECT s.*, COUNT(m.id) as message_count + f"""SELECT s.*, COUNT(m.id) as message_count FROM sessions s LEFT JOIN messages m ON s.id = m.session_id + {where} GROUP BY s.id - ORDER BY s.updated_at DESC + {having} + ORDER BY s.updated_at DESC, COALESCE(MAX(m.id), 0) DESC, s.rowid DESC LIMIT ?""", - (limit,), + (*params, limit), ) sessions = [dict(row) for row in cursor.fetchall()] @@ -242,6 +275,32 @@ def list_sessions(self, limit: int = 20) -> list[dict]: return sessions + def count_sessions( + self, cwd: str | os.PathLike | None = None, include_empty: bool = False + ) -> int: + """How many sessions exist (optionally only those started in ``cwd``).""" + return len(self.list_sessions(limit=1_000_000, cwd=cwd, include_empty=include_empty)) + + def first_user_message(self, session_id: str, limit: int = 120) -> str: + """The first thing the user typed in a session, flattened to one line (a recognisable title).""" + conn = sqlite3.connect(DB_PATH) + row = conn.execute( + "SELECT content FROM messages WHERE session_id = ? AND role = 'user' ORDER BY id LIMIT 1", + (session_id,), + ).fetchone() + conn.close() + text = " ".join((row[0] if row else "").split()) + # drop the "Attached context:" block that @mentions append to a prompt + text = text.split(" Attached context:")[0] + return text if len(text) <= limit else text[: limit - 1] + "…" + + def latest_session_id(self, cwd: str | os.PathLike | None = None) -> str | None: + """The most recently used non-empty session started in ``cwd`` (default: this directory).""" + found = self.list_sessions( + limit=1, cwd=cwd if cwd is not None else os.getcwd(), include_empty=False + ) + return found[0]["id"] if found else None + def delete_session(self, session_id: str) -> bool: """Delete a session and its messages.""" conn = sqlite3.connect(DB_PATH) diff --git a/tests/conftest.py b/tests/conftest.py index 5f7da2b..1e3369d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,9 @@ import os import shutil import tempfile +from pathlib import Path + +import pytest _TEST_HOME = tempfile.mkdtemp(prefix="devorch-test-home-") @@ -41,3 +44,14 @@ def pytest_sessionfinish(session, exitstatus): shutil.rmtree(_TEST_HOME, ignore_errors=True) + + +@pytest.fixture(autouse=True) +def _fresh_devorch_home(): + """Every test starts with an empty ~/.devorch (inside the throwaway home). + + The home is shared for the whole run, so without this, sessions, permissions and settings saved by + one test would be visible to the next and tests would depend on their order. + """ + shutil.rmtree(Path(_TEST_HOME) / ".devorch", ignore_errors=True) + yield diff --git a/tests/test_cli_options.py b/tests/test_cli_options.py new file mode 100644 index 0000000..8bdb067 --- /dev/null +++ b/tests/test_cli_options.py @@ -0,0 +1,84 @@ +"""--version, --continue, bare --resume and the entry-point argv handling.""" + +import re +import sys +from pathlib import Path + +import pytest +import typer.main +from typer.testing import CliRunner + +import cli.main as cli_main +from cli.constants import VERSION +from cli.repl_commands import PICK_SESSION + +normalize = cli_main.normalize_resume_argv + + +@pytest.mark.parametrize( + ("argv", "expected"), + [ + ([], []), + (["--resume"], ["--resume", PICK_SESSION]), + (["-r"], ["-r", PICK_SESSION]), + (["--resume", "abc123"], ["--resume", "abc123"]), + (["-r", "abc123"], ["-r", "abc123"]), + (["--resume=abc123"], ["--resume=abc123"]), + (["--resume", "-c"], ["--resume", PICK_SESSION, "-c"]), + (["-r", "--mode", "plan"], ["-r", PICK_SESSION, "--mode", "plan"]), + (["-p", "openai", "--resume"], ["-p", "openai", "--resume", PICK_SESSION]), + (["--continue"], ["--continue"]), + (["ask", "hello"], ["ask", "hello"]), + (["--resume", "--resume"], ["--resume", PICK_SESSION, "--resume", PICK_SESSION]), + ], +) +def test_bare_resume_becomes_the_picker_marker(argv, expected): + assert normalize(argv) == expected + + +def test_normalizing_does_not_mutate_its_input(): + argv = ["--resume"] + normalize(argv) + assert argv == ["--resume"] + + +def test_entry_point_passes_the_normalized_arguments(monkeypatch): + captured = {} + monkeypatch.setattr(cli_main, "app", lambda args=None: captured.update(args=args)) + monkeypatch.setattr(sys, "argv", ["devorch", "--resume"]) + cli_main.main() + assert captured["args"] == ["--resume", PICK_SESSION] + + +def test_version_flag_prints_and_exits_without_starting_anything(): + runner = CliRunner() + for flag in ("--version", "-V"): + result = runner.invoke(cli_main.app, [flag]) + assert result.exit_code == 0 and result.output.strip() == f"devorch {VERSION}" + + +def test_version_matches_pyproject(): + # a release guard: the version is declared in two places and they must agree + text = ( + Path(__file__).resolve().parents[1].joinpath("pyproject.toml").read_text(encoding="utf-8") + ) + declared = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE).group(1) + assert declared == VERSION + + +def test_continue_and_resume_are_options_of_the_repl_entry_points(): + root = typer.main.get_command(cli_main.app) + for name, command in {"devorch": root, "chat": root.commands["chat"]}.items(): + opts = {o for p in command.params for o in getattr(p, "opts", [])} + assert {"--continue", "-c", "--resume", "-r"} <= opts, name + assert "--version" in {o for p in root.params for o in getattr(p, "opts", [])} + + +def test_doctor_is_a_command(): + assert "doctor" in typer.main.get_command(cli_main.app).commands + + +def test_sessions_list_has_a_here_option(): + sessions = typer.main.get_command(cli_main.app).commands["sessions"] + opts = {o for p in sessions.commands["list"].params for o in getattr(p, "opts", [])} + assert "--here" in opts diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..76126ef --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,191 @@ +"""Tests for `devorch doctor`.""" + +import sys +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner + +import cli.main as cli_main +from config.permissions import PermissionLevel, Permissions +from config.project_settings import trust_root +from config.settings import Settings +from core import doctor +from core.doctor import FAIL, INFO, OK, WARN, exit_code, run_checks + +SECRET = "sk-THIS-MUST-NEVER-BE-PRINTED-1234567890" + + +def by_name(rows, name): + return [r for r in rows if r.name == name] + + +@pytest.fixture +def project(tmp_path, monkeypatch): + proj = tmp_path / "proj" + (proj / ".devorch").mkdir(parents=True) + monkeypatch.chdir(proj) + return proj + + +def test_every_row_has_a_valid_status_and_the_expected_sections(): + rows = run_checks() + assert all(r.status in {OK, WARN, FAIL, INFO} for r in rows) + names = {r.name for r in rows} + assert { + "DevOrch", + "Python", + "API keys", + "Default provider", + "Permissions", + "git", + "Terminal", + } <= names + + +def test_api_key_values_never_appear(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", SECRET) + monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET + "-2") + rows = run_checks() + blob = " ".join(f"{r.name} {r.detail} {r.hint}" for r in rows) + assert SECRET not in blob and "THIS-MUST-NEVER" not in blob + (keys,) = by_name(rows, "API keys") + assert keys.status == OK and "openai" in keys.detail and "anthropic" in keys.detail + + +def test_cli_output_never_contains_a_key(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", SECRET) + result = CliRunner().invoke(cli_main.app, ["doctor"]) + assert SECRET not in result.output and "THIS-MUST-NEVER" not in result.output + + +def test_default_provider_without_a_key_is_a_failure(): + rows = run_checks() # the test environment has no keys + (default,) = by_name(rows, "Default provider") + assert default.status == FAIL and "no API key" in default.detail and "set-key" in default.hint + assert exit_code(rows) == 1 + + +def test_default_provider_with_a_key_passes(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", SECRET) + rows = run_checks() + assert by_name(rows, "Default provider")[0].status == OK + assert exit_code(rows) == 0 + + +def test_local_default_provider_needs_no_key(): + settings = Settings.load() + settings.default_provider = "local" + rows = run_checks(settings=settings) + assert by_name(rows, "Default provider")[0].status == OK + + +def test_permissions_warning_when_edits_are_pre_approved(): + # the exact situation a stray test run used to leave behind + perms = Permissions() + perms.set_tool_permission("edit", PermissionLevel.ALLOW) + perms.set_tool_permission("filesystem", PermissionLevel.ALLOW) + (row,) = by_name(run_checks(), "Permissions") + assert row.status == WARN and "edit" in row.detail and "filesystem" in row.detail + assert "permissions reset" in row.hint + + +def test_permissions_ok_with_defaults(): + (row,) = by_name(run_checks(), "Permissions") + assert row.status == OK + + +def test_project_trust_is_reported(project): + (project / ".devorch" / "hooks.yaml").write_text( + "hooks:\n PreToolUse:\n - command: ./g.sh\n" + ) + (row,) = by_name(run_checks(cwd=project), "Project settings/hooks") + assert row.status == WARN and "NOT trusted" in row.detail and "devorch trust" in row.hint + trust_root(project) + (row,) = by_name(run_checks(cwd=project), "Project settings/hooks") + assert row.status == OK and "NOT" not in row.detail + + +def test_project_without_devorch_files(project): + (row,) = by_name(run_checks(cwd=project), "Project settings/hooks") + assert row.status == INFO + + +def test_project_instructions_detected(project): + (project / "CLAUDE.md").write_text("rules") + (row,) = by_name(run_checks(cwd=project), "Project instructions") + assert row.status == OK and "CLAUDE.md" in row.detail + + +def test_a_crashing_check_becomes_a_failed_row_and_the_rest_still_run(monkeypatch): + def boom(cwd): + raise RuntimeError("kaboom") + + monkeypatch.setattr(doctor, "check_git", boom) + rows = run_checks() + (git,) = [r for r in rows if r.name == "git"] + assert git.status == FAIL and "kaboom" in git.detail and "RuntimeError" in git.detail + assert by_name(rows, "Python") and by_name(rows, "Terminal") # the report continued + assert exit_code(rows) == 1 + + +def test_git_row(project, monkeypatch): + monkeypatch.setattr(doctor.shutil, "which", lambda name: None) + (row,) = doctor.check_git(project) + assert row.status == WARN and "not installed" in row.detail + + +def test_non_utf8_terminal_is_a_warning(monkeypatch): + monkeypatch.setattr(sys, "stdout", SimpleNamespace(encoding="cp1252", isatty=lambda: False)) + rows = doctor.check_terminal() + (enc,) = [r for r in rows if r.name == "Text encoding"] + assert enc.status == WARN and "UTF-8" in enc.hint + + +def test_unwritable_config_folder_is_a_failure(monkeypatch, tmp_path): + from config import settings as settings_mod + + folder = tmp_path / "cfg" + folder.mkdir() + monkeypatch.setattr(settings_mod, "CONFIG_DIR", folder) + monkeypatch.setattr(settings_mod, "CONFIG_FILE", folder / "config.yaml") + monkeypatch.setattr(doctor.os, "access", lambda path, mode: False) + assert doctor.check_config_dir()[0].status == FAIL + + +def test_sessions_row_counts_saved_sessions(): + from core.sessions import SessionManager + + sm = SessionManager() + sm.create_session("openai", "gpt-4o") + sm.create_session("openai", "gpt-4o") + (row,) = doctor.check_sessions() + assert row.status == OK and row.detail.startswith("2 saved") + + +def test_doctor_command_exit_code_follows_the_checks(monkeypatch): + runner = CliRunner() + failing = runner.invoke(cli_main.app, ["doctor"]) + assert ( + failing.exit_code == 1 + and "DevOrch doctor" in failing.output + and "problems" in failing.output + ) + monkeypatch.setenv("OPENAI_API_KEY", SECRET) + passing = runner.invoke(cli_main.app, ["doctor"]) + assert passing.exit_code == 0 + + +def test_doctor_output_is_safe_with_markup_like_text(monkeypatch, project): + # paths and error text are shown verbatim, never parsed as rich markup + (project / "DEVORCH.md").write_text("x") + monkeypatch.setattr( + doctor, + "check_runtime", + lambda: [ + doctor.Check("Odd [/bold] name", WARN, "detail [red]x[/red] and [/]", "hint [/dim]") + ], + ) + result = CliRunner().invoke(cli_main.app, ["doctor"]) + assert "Odd [/bold] name" in result.output and "detail [red]x[/red] and [/]" in result.output + assert "hint [/dim]" in result.output diff --git a/tests/test_sessions_continue.py b/tests/test_sessions_continue.py new file mode 100644 index 0000000..29f6da2 --- /dev/null +++ b/tests/test_sessions_continue.py @@ -0,0 +1,408 @@ +"""Sessions remember their directory; --continue / picker / /resume build on that.""" + +import os +import sqlite3 +import sys +from types import SimpleNamespace + +import pytest + +from cli import repl_commands as rc +from cli.repl_commands import ( + ALL_DIRECTORIES, + PICK_SESSION, + handle_resume, + last_exchange_preview, + pick_session, + resolve_startup_session, +) +from core import sessions as sessions_mod +from core.sessions import SessionManager, normalize_dir +from schemas.message import Message + + +@pytest.fixture +def sm(): + return SessionManager() + + +def talk(sm, sid, *texts): + """Add alternating user/assistant messages to a session.""" + for i, t in enumerate(texts): + sm.save_message( + Message(role="user" if i % 2 == 0 else "assistant", content=t), session_id=sid + ) + + +@pytest.fixture +def dirs(tmp_path): + a, b = tmp_path / "proj_a", tmp_path / "proj_b" + a.mkdir() + b.mkdir() + return a, b + + +# ── storage ───────────────────────────────────────────────────────────────── + + +def test_old_database_is_migrated_and_old_sessions_have_no_directory(): + sessions_mod.DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(sessions_mod.DB_PATH) + conn.execute( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, name TEXT, provider TEXT NOT NULL, model TEXT NOT NULL," + " parent_session_id TEXT, summary TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP," + " updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" + ) + conn.execute( + "INSERT INTO sessions (id, name, provider, model) VALUES ('old1', 'legacy', 'openai', 'gpt-4o')" + ) + conn.commit() + conn.close() + sm = SessionManager() # must not crash on the old schema + (row,) = sm.list_sessions() + assert row["id"] == "old1" and row["cwd"] is None + new = sm.create_session("openai", "gpt-4o") + assert any(s["id"] == new and s["cwd"] for s in sm.list_sessions()) + + +def test_session_records_a_normalized_directory(sm, dirs): + a, _ = dirs + sid = sm.create_session("openai", "gpt-4o", cwd=a) + (row,) = [s for s in sm.list_sessions() if s["id"] == sid] + assert row["cwd"] == normalize_dir(a) + + +def test_default_directory_is_the_current_one(sm, dirs, monkeypatch): + a, _ = dirs + monkeypatch.chdir(a) + sid = sm.create_session("openai", "gpt-4o") + assert [s["cwd"] for s in sm.list_sessions() if s["id"] == sid] == [normalize_dir(a)] + + +@pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") +def test_a_symlinked_directory_counts_as_the_same_place(sm, dirs, tmp_path): + a, _ = dirs + link = tmp_path / "link_to_a" + os.symlink(a, link) + sid = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, sid, "hi") + assert sm.latest_session_id(link) == sid + + +def test_listing_filters_by_directory_and_hides_empty_sessions(sm, dirs): + a, b = dirs + empty = sm.create_session("openai", "gpt-4o", cwd=a) + used_a = sm.create_session("openai", "gpt-4o", cwd=a) + used_b = sm.create_session("openai", "gpt-4o", cwd=b) + talk(sm, used_a, "hello") + talk(sm, used_b, "hello") + ids = lambda **kw: {s["id"] for s in sm.list_sessions(**kw)} # noqa: E731 + assert ids(cwd=a) == {empty, used_a} + assert ids(cwd=a, include_empty=False) == {used_a} + assert ids(include_empty=False) == {used_a, used_b} + assert sm.count_sessions(cwd=a) == 1 and sm.count_sessions() == 2 + assert sm.count_sessions(include_empty=True) == 3 + + +def test_latest_session_ignores_a_newer_empty_one(sm, dirs): + # DevOrch creates a session at startup even if nothing is typed; --continue must skip those + a, _ = dirs + real = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, real, "real work") + sm.create_session("openai", "gpt-4o", cwd=a) # opened and closed again: empty and newer + assert sm.latest_session_id(a) == real + + +def test_latest_session_is_per_directory_and_skips_unknown_directories(sm, dirs): + a, b = dirs + in_a = sm.create_session("openai", "gpt-4o", cwd=a) + in_b = sm.create_session("openai", "gpt-4o", cwd=b) + talk(sm, in_a, "x") + talk(sm, in_b, "y") + conn = sqlite3.connect(sessions_mod.DB_PATH) + conn.execute("INSERT INTO sessions (id, provider, model, cwd) VALUES ('nodir','p','m',NULL)") + conn.execute( + "INSERT INTO messages (session_id, role, content) VALUES ('nodir','user','legacy')" + ) + conn.commit() + conn.close() + assert sm.latest_session_id(a) == in_a and sm.latest_session_id(b) == in_b + assert sm.latest_session_id(a / "nowhere") is None + + +def test_most_recently_used_wins(sm, dirs): + a, _ = dirs + first = sm.create_session("openai", "gpt-4o", cwd=a) + second = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, first, "a") + talk(sm, second, "b") + assert sm.latest_session_id(a) == second + sm.save_message(Message(role="user", content="more"), session_id=first) # touch the older one + assert sm.latest_session_id(a) == first + + +def test_continuation_sessions_keep_the_directory(sm, dirs, monkeypatch): + a, _ = dirs + monkeypatch.chdir(a) + sm.create_session("openai", "gpt-4o") + new = sm.create_continuation_session("openai", "gpt-4o", "summary") + assert [s["cwd"] for s in sm.list_sessions() if s["id"] == new] == [normalize_dir(a)] + + +def test_messages_load_in_the_order_they_were_saved(sm): + sid = sm.create_session("openai", "gpt-4o") + texts = [f"m{i}" for i in range(30)] # all saved within the same second + talk(sm, sid, *texts) + _, messages = sm.load_session(sid) + assert [m.content for m in messages] == texts + + +def test_first_user_message_is_a_flat_title(sm): + sid = sm.create_session("openai", "gpt-4o") + talk(sm, sid, "fix the\nlogin bug in @auth.py\n\nAttached context:\n\n...", "ok") + assert sm.first_user_message(sid) == "fix the login bug in @auth.py" + assert sm.first_user_message("missing") == "" + long = sm.create_session("openai", "gpt-4o") + talk(sm, long, "word " * 100) + assert len(sm.first_user_message(long, limit=30)) == 30 and sm.first_user_message( + long, limit=30 + ).endswith("…") + + +# ── choosing a session ────────────────────────────────────────────────────── + + +class Chooser: + """Records what the picker offered and answers with a scripted choice.""" + + def __init__(self, *answers): + self.answers = list(answers) + self.offered = [] + + def __call__(self, title, choices): + self.offered.append((title, choices)) + answer = self.answers.pop(0) + return answer(choices) if callable(answer) else answer + + +def first(choices): + return choices[0][1] + + +def test_picker_shows_this_directory_with_an_option_for_all(sm, dirs): + a, b = dirs + s1 = sm.create_session("openai", "gpt-4o", cwd=a) + s2 = sm.create_session("openai", "gpt-4o", cwd=b) + talk(sm, s1, "in a") + talk(sm, s2, "in b") + chooser = Chooser(first) + assert pick_session(sm, a, chooser) == s1 + title, choices = chooser.offered[0] + assert "this directory" in title + values = [v for _, v in choices] + assert values == [s1, ALL_DIRECTORIES] + assert "in a" in choices[0][0] and "in b" not in choices[0][0] + + +def test_picker_can_widen_to_all_directories(sm, dirs): + a, b = dirs + s1 = sm.create_session("openai", "gpt-4o", cwd=a) + s2 = sm.create_session("openai", "gpt-4o", cwd=b) + talk(sm, s1, "in a") + talk(sm, s2, "in b") + chooser = Chooser(ALL_DIRECTORIES, lambda choices: choices[-1][1]) + picked = pick_session(sm, a, chooser) + assert picked in (s1, s2) + widened = [v for _, v in chooser.offered[1][1]] + assert set(widened) == {s1, s2} and ALL_DIRECTORIES not in widened + assert "this directory" not in chooser.offered[1][0] + assert "proj_" in chooser.offered[1][1][0][0] # labels show where each session lives + + +def test_no_all_option_when_everything_is_already_here(sm, dirs): + a, _ = dirs + s1 = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, s1, "x") + chooser = Chooser(first) + pick_session(sm, a, chooser) + assert ALL_DIRECTORIES not in [v for _, v in chooser.offered[0][1]] + + +def test_picker_falls_back_to_all_when_this_directory_has_none(sm, dirs): + a, b = dirs + s2 = sm.create_session("openai", "gpt-4o", cwd=b) + talk(sm, s2, "elsewhere") + chooser = Chooser(first) + assert pick_session(sm, a, chooser) == s2 + assert "this directory" not in chooser.offered[0][0] + + +def test_picker_with_nothing_saved_or_cancelled(sm, dirs): + a, _ = dirs + never = Chooser() + assert pick_session(sm, a, never) is None and never.offered == [] + sm.create_session("openai", "gpt-4o", cwd=a) # empty sessions do not count + assert pick_session(sm, a, never) is None + s1 = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, s1, "x") + assert pick_session(sm, a, Chooser(None)) is None + + +def test_startup_explicit_id_beats_continue(sm, dirs): + a, _ = dirs + other = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, other, "x") + assert resolve_startup_session(sm, "abc123", True, a).session_id == "abc123" + + +def test_startup_continue(sm, dirs): + a, _ = dirs + none = resolve_startup_session(sm, None, True, a) + assert none.session_id is None and "starting a new session" in none.notice + s1 = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, s1, "x") + assert resolve_startup_session(sm, None, True, a).session_id == s1 + assert ( + resolve_startup_session(sm, None, False, a).session_id is None + ) # plain start: new session + + +def test_startup_bare_resume_opens_the_picker(sm, dirs): + a, _ = dirs + s1 = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, s1, "x") + assert resolve_startup_session(sm, PICK_SESSION, False, a, Chooser(first)).session_id == s1 + cancelled = resolve_startup_session(sm, PICK_SESSION, False, a, Chooser(None)) + assert cancelled.cancelled and cancelled.session_id is None + + +def test_startup_bare_resume_with_no_sessions_just_starts_fresh(sm, dirs): + a, _ = dirs + choice = resolve_startup_session(sm, PICK_SESSION, False, a, Chooser()) + assert choice.session_id is None and not choice.cancelled and choice.notice + + +# ── /resume inside the REPL ───────────────────────────────────────────────── + + +class FakeAgent: + def __init__(self): + self.history = [Message(role="user", content="current conversation")] + self.summary = "unchanged" + self.provider = SimpleNamespace(name="openai", model="gpt-4o") + + def set_history(self, messages): + self.history = messages + + def set_context_summary(self, summary): + self.summary = summary + + +@pytest.fixture +def repl(sm, dirs): + a, _ = dirs + from core.checkpoints import CheckpointManager + + agent = FakeAgent() + executor = SimpleNamespace(checkpoints=CheckpointManager()) + executor.checkpoints.begin_turn("old") + executor.checkpoints.snapshot(str(a / "f.txt")) + return sm, agent, executor, a + + +def test_resume_switches_history_session_and_resets_per_conversation_state(repl, capsys): + sm, agent, executor, a = repl + target = sm.create_session("openai", "gpt-4o", cwd=a, summary="earlier summary") + talk(sm, target, "first question", "first answer") + current = sm.create_session("openai", "gpt-4o", cwd=a) + old_checkpoints = executor.checkpoints + assert handle_resume(target, sm, agent, executor) is True + assert sm.current_session_id == target + assert [m.content for m in agent.history] == ["first question", "first answer"] + assert agent.summary == "earlier summary" + assert executor.checkpoints is not old_checkpoints and executor.checkpoints.summary() == [] + assert current != target + + +def test_resume_notes_a_different_provider_and_keeps_the_current_one(repl, capsys, monkeypatch): + sm, agent, executor, a = repl + buf = [] + monkeypatch.setattr(rc, "print_info", buf.append) + other = sm.create_session("anthropic", "claude", cwd=a) + talk(sm, other, "hi", "hello") + sm.create_session("openai", "gpt-4o", cwd=a) # we are currently in a different session + handle_resume(other, sm, agent, executor) + assert any("anthropic/claude" in m and "still using openai/gpt-4o" in m for m in buf) + assert agent.provider.name == "openai" + + +def test_resume_errors(repl, monkeypatch): + sm, agent, executor, a = repl + errors, infos = [], [] + monkeypatch.setattr(rc, "print_error", errors.append) + monkeypatch.setattr(rc, "print_info", infos.append) + before = list(agent.history) + assert handle_resume("nope", sm, agent, executor) is False and errors + assert agent.history == before # a failed resume changes nothing + cur = sm.create_session("openai", "gpt-4o", cwd=a) + assert handle_resume(cur, sm, agent, executor) is False and any("Already" in m for m in infos) + assert handle_resume(None, sm, agent, executor, select=Chooser()) is False # nothing to pick + + +def test_resume_without_an_id_uses_the_picker(repl): + sm, agent, executor, a = repl + target = sm.create_session("openai", "gpt-4o", cwd=a) + talk(sm, target, "q", "a") + sm.create_session("openai", "gpt-4o", cwd=a) + assert handle_resume("", sm, agent, executor, select=Chooser(first)) is True + assert sm.current_session_id == target + assert handle_resume(None, sm, agent, executor, select=Chooser(None)) is False + + +def test_last_exchange_preview(): + msgs = [ + Message(role="user", content="old"), + Message(role="user", content="fix @a.py\n\nAttached context:\n\nx"), + Message(role="assistant", content="Calling tool..."), + Message(role="tool", content="output"), + Message(role="assistant", content="Done: fixed it"), + Message(role="assistant", content="[The user interrupted this turn before it finished.]"), + ] + assert last_exchange_preview(msgs) == ["You: fix @a.py", "DevOrch: Done: fixed it"] + assert last_exchange_preview([]) == [] + long = last_exchange_preview([Message(role="user", content="x" * 500)], limit=40) + assert len(long[0]) == len("You: ") + 40 and long[0].endswith("…") + + +def test_sessions_table_lists_here_only(sm, dirs, monkeypatch, capsys): + a, b = dirs + sa = sm.create_session("openai", "gpt-4o", cwd=a) + sb = sm.create_session("openai", "gpt-4o", cwd=b) + import io + + from rich.console import Console + + buf = io.StringIO() + monkeypatch.setattr(rc, "console", Console(file=buf, width=200, color_system=None)) + monkeypatch.chdir(a) + rc.print_sessions_table(sm, here=True) + assert sa in buf.getvalue() and sb not in buf.getvalue() + buf.truncate(0) + buf.seek(0) + rc.print_sessions_table(sm) + assert sa in buf.getvalue() and sb in buf.getvalue() and "Directory" in buf.getvalue() + + +def test_slash_sessions_list_here_argument(sm, dirs, monkeypatch): + a, b = dirs + sm.create_session("openai", "gpt-4o", cwd=a) + seen = [] + monkeypatch.setattr(rc, "print_sessions_table", lambda *args, **kw: seen.append(kw.get("here"))) + rc.handle_sessions("list here", sm, None) + rc.handle_sessions("list 5", sm, None) + rc.handle_sessions("list 5 here", sm, None) + assert seen == [True, False, True] + errors = [] + monkeypatch.setattr(rc, "print_error", errors.append) + rc.handle_sessions("list abc", sm, None) + assert errors and "Usage" in errors[0]