diff --git a/code_puppy/command_line/session_commands.py b/code_puppy/command_line/session_commands.py index 42e9f1d2e..c8c09aa29 100644 --- a/code_puppy/command_line/session_commands.py +++ b/code_puppy/command_line/session_commands.py @@ -9,6 +9,7 @@ from code_puppy.command_line.command_registry import register_command from code_puppy.config import AUTOSAVE_DIR +from code_puppy.i18n import t from code_puppy.session_storage import list_sessions, load_session logger = logging.getLogger(__name__) @@ -73,15 +74,18 @@ def handle_session_command(command: str) -> bool: if len(tokens) == 1 or tokens[1] == "id": session_name = get_current_session_name() emit_info( - f"[bold magenta]Autosave Session[/bold magenta]: {session_name}\n" - f"Files prefix: {Path(AUTOSAVE_DIR) / session_name}" + t( + "cmd.session.info", + name=session_name, + prefix=str(Path(AUTOSAVE_DIR) / session_name), + ) ) return True if tokens[1] == "new": new_name = rotate_session_name() - emit_success(f"New autosave session: {new_name}") + emit_success(t("cmd.session.new", name=new_name)) return True - emit_warning("Usage: /session [id|new]") + emit_warning(t("cmd.session.usage")) return True @@ -113,16 +117,16 @@ def handle_clear_command(command: str) -> bool: agent = get_current_agent() new_session_id = finalize_autosave_session() agent.clear_message_history() - emit_warning("Conversation history cleared!") - emit_system_message("The agent will not remember previous interactions.") - emit_info(f"Auto-save session rotated to: {new_session_id}") + emit_warning(t("cmd.clear.cleared")) + emit_system_message(t("cmd.clear.agent_notice")) + emit_info(t("cmd.clear.session_rotated", id=new_session_id)) # Also clear pending clipboard images so they don't leak into the next turn clipboard_manager = get_clipboard_manager() clipboard_count = clipboard_manager.get_pending_count() clipboard_manager.clear_pending() if clipboard_count > 0: - emit_info(f"Cleared {clipboard_count} pending clipboard image(s)") + emit_info(t("cmd.clear.clipboard_cleared", count=clipboard_count)) return True @@ -145,13 +149,13 @@ def handle_compact_command(command: str) -> bool: from code_puppy.messaging.pause_controller import get_pause_controller get_pause_controller().request_compaction() - emit_info("Compaction requested; it will run before the next model call.") + emit_info(t("cmd.compact.queued")) return True agent = get_current_agent() history = agent.get_message_history() if not history: - emit_warning("No history to compact yet. Ask me something first!") + emit_warning(t("cmd.compact.no_history")) return True current_agent = get_current_agent() @@ -161,7 +165,12 @@ def handle_compact_command(command: str) -> bool: compaction_strategy = get_compaction_strategy() protected_tokens = get_protected_token_count() emit_info( - f"šŸ¤” Compacting {len(history)} messages using {compaction_strategy} strategy... (~{before_tokens} tokens)" + t( + "cmd.compact.compacting", + count=len(history), + strategy=compaction_strategy, + tokens=f"{before_tokens:,}", + ) ) current_agent = get_current_agent() @@ -177,7 +186,7 @@ def handle_compact_command(command: str) -> bool: ) if not compacted: - emit_error("Compaction failed. History unchanged.") + emit_error(t("cmd.compact.failed")) return True agent.set_message_history(compacted) @@ -192,18 +201,29 @@ def handle_compact_command(command: str) -> bool: else 0 ) - strategy_info = ( - f"using {compaction_strategy} strategy" + # Whole-sentence keys per strategy so translators can reorder or + # inflect the verb naturally. Do NOT reintroduce a shared success + # template with a ``{strategy_info}`` fragment -- fragment gluing + # doesn't agree grammatically outside English. + success_key = ( + "cmd.compact.success.truncation" if compaction_strategy == "truncation" - else "via summarization" + else "cmd.compact.success.summarization" ) emit_success( - f"✨ Done! History: {len(history)} → {len(compacted)} messages {strategy_info}\n" - f"šŸ¦ Tokens: {before_tokens:,} → {after_tokens:,} ({reduction_pct:.1f}% reduction)" + t( + success_key, + before_count=len(history), + after_count=len(compacted), + strategy=compaction_strategy, + before_tokens=f"{before_tokens:,}", + after_tokens=f"{after_tokens:,}", + reduction_pct=f"{reduction_pct:.1f}", + ) ) return True except Exception as e: - emit_error(f"/compact error: {e}") + emit_error(t("cmd.compact.error", error=e)) return True @@ -220,28 +240,26 @@ def handle_truncate_command(command: str) -> bool: tokens = command.split() if len(tokens) != 2: - emit_error("Usage: /truncate (where N is the number of messages to keep)") + emit_error(t("cmd.truncate.usage")) return True try: n = int(tokens[1]) if n < 1: - emit_error("N must be a positive integer") + emit_error(t("cmd.truncate.must_be_positive")) return True except ValueError: - emit_error("N must be a valid integer") + emit_error(t("cmd.truncate.invalid_int")) return True agent = get_current_agent() history = agent.get_message_history() if not history: - emit_warning("No history to truncate yet. Ask me something first!") + emit_warning(t("cmd.truncate.no_history")) return True if len(history) <= n: - emit_info( - f"History already has {len(history)} messages, which is <= {n}. Nothing to truncate." - ) + emit_info(t("cmd.truncate.already_short", current=len(history), n=n)) return True # Always keep the first message (system message) and then keep the N-1 most recent messages @@ -249,7 +267,12 @@ def handle_truncate_command(command: str) -> bool: agent.set_message_history(truncated_history) emit_success( - f"Truncated message history from {len(history)} to {len(truncated_history)} messages (keeping system message and {n - 1} most recent)" + t( + "cmd.truncate.success", + before=len(history), + after=len(truncated_history), + kept=n - 1, + ) ) return True @@ -300,15 +323,12 @@ def handle_quick_resume_command(command: str) -> bool: # Diagnostic identifies the scope without leaking full local paths. cwd, branch = get_quick_resume_location(target_path) emit_info( - "Quick Resume selected - finding latest session for " - f"{format_quick_resume_scope(cwd, branch)}" + t("cmd.quick_resume.searching", scope=format_quick_resume_scope(cwd, branch)) ) quick_resume_pickle = resolve_quick_resume_pickle(target_path) if not quick_resume_pickle: - emit_info( - "No previous session found for this scope; staying in current session." - ) + emit_info(t("cmd.quick_resume.no_session")) return True session_path = Path(quick_resume_pickle) @@ -318,13 +338,11 @@ def handle_quick_resume_command(command: str) -> bool: history = load_session(session_name, session_path.parent) except FileNotFoundError: logger.warning("Quick-resume session file not found: %s", session_path) - emit_error( - "Quick-resume session file was not found; staying in current session." - ) + emit_error(t("cmd.quick_resume.file_not_found")) return True except Exception: logger.exception("Failed to quick-resume from %s", session_path) - emit_error("Quick-resume failed; staying in current session.") + emit_error(t("cmd.quick_resume.failed")) return True agent = get_current_agent() @@ -332,9 +350,7 @@ def handle_quick_resume_command(command: str) -> bool: set_current_autosave_from_session_name(session_name) total_tokens = sum(agent.estimate_tokens_for_message(m) for m in history) - emit_success( - f"Quick resume loaded: {len(history)} messages ({total_tokens} tokens)" - ) + emit_success(t("cmd.quick_resume.success", count=len(history), tokens=total_tokens)) # Best-effort history preview; failure must not abort a successful resume. try: @@ -364,47 +380,37 @@ def handle_dump_context_command(command: str) -> bool: tokens = command.split() if len(tokens) != 2: - emit_warning("Usage: /dump_context ") + emit_warning(t("cmd.dump_context.usage")) return True session_name = tokens[1] - # Enforce reserved-prefix + slug rules at every user-input write site - # (the resolver enforces them for ``-r NAME``; this is the parallel - # gate for ``/dump_context``). Without it, /dump_context bypasses - # the validator that ``-r`` runs and lets a user squat the - # ``auto_session_`` namespace or smuggle in a path-traversal name. if not is_valid_session_name(session_name, allow_reserved_prefix=False): - emit_error( - f"Invalid session name: {session_name!r}. " - "Session names must be 1-128 chars of [A-Za-z0-9._-] " - "and may not start with 'auto_session_' (reserved)." - ) + emit_error(t("cmd.dump_context.invalid_name", name=repr(session_name))) return True agent = get_current_agent() if not agent.get_message_history(): - emit_warning("No message history to dump!") + emit_warning(t("cmd.dump_context.no_history")) return True try: # The user-facing success line is preserved verbatim via - # ``success_message_template`` so /dump_context UX doesn't - # regress. The silent save-back paths (``-r``, periodic - # autosave) omit the template and stay quiet. + # ``success_message_key`` so /dump_context UX doesn't regress. + # The silent save-back paths (``-r``, periodic autosave) omit the + # key and stay quiet. NOTE: we pass a *catalog key*, not raw text -- + # ``t()`` is the only safe interpolator for catalog strings + # (see docs/I18N.md); routing catalog text through ``str.format`` + # is forbidden. persist_named_session( agent, session_name, base_dir=Path(AUTOSAVE_DIR), - success_message_template=( - "\u2705 Context saved: {message_count} messages " - "({total_tokens} tokens)\n" - "\U0001f4c1 Files: {pickle_path}, {metadata_path}" - ), + success_message_key="cmd.dump_context.success", ) return True except Exception as exc: - emit_error(f"Failed to dump context: {exc}") + emit_error(t("cmd.dump_context.failed", error=exc)) return True @@ -422,7 +428,7 @@ def handle_load_context_command(command: str) -> bool: tokens = command.split() if len(tokens) != 2: - emit_warning("Usage: /load_context ") + emit_warning(t("cmd.load_context.usage")) return True session_name = tokens[1] @@ -432,13 +438,13 @@ def handle_load_context_command(command: str) -> bool: try: history = load_session(session_name, sessions_dir) except FileNotFoundError: - emit_error(f"Context file not found: {session_path}") + emit_error(t("cmd.load_context.not_found", path=session_path)) available = list_sessions(sessions_dir) if available: - emit_info(f"Available contexts: {', '.join(available)}") + emit_info(t("cmd.load_context.available", contexts=", ".join(available))) return True except Exception as exc: - emit_error(f"Failed to load context: {exc}") + emit_error(t("cmd.load_context.failed", error=exc)) return True agent = get_current_agent() @@ -473,12 +479,14 @@ def handle_load_context_command(command: str) -> bool: new_autosave_id = rotate_session_name() emit_success( - f"\u2705 Context loaded: {len(history)} messages " - f"({total_tokens} tokens)\n" - f"\U0001f4c1 From: {session_path}\n" - f"\U0001f504 Autosave rotated to: {new_autosave_id} " - f"(snapshot at {session_path.name} is preserved; further " - f"autosaves land in the new session)" + t( + "cmd.load_context.success", + count=len(history), + tokens=total_tokens, + path=session_path, + session_id=new_autosave_id, + file=session_path.name, + ) ) # Display recent message history for context diff --git a/code_puppy/i18n/locales/en-US.json b/code_puppy/i18n/locales/en-US.json index 485e58bba..40b6108d5 100644 --- a/code_puppy/i18n/locales/en-US.json +++ b/code_puppy/i18n/locales/en-US.json @@ -1,6 +1,6 @@ { "startup.welcome": "Welcome to Code Puppy, {name}!", - "startup.ready": "Ready to fetch some code. \ud83d\udc36", + "startup.ready": "Ready to fetch some code. 🐶", "locale.set": "Locale set to {locale}.", "locale.current": "Current locale: {locale}", "files.deleted": { @@ -22,21 +22,21 @@ "version.undetected": "Could not detect current version, using fallback", "confirm.yes": "Yes", "confirm.no": "No", - "cli.loading": "\ud83d\udc36 Code Puppy is Loading...", + "cli.loading": "🐶 Code Puppy is Loading...", "cli.error.no_ports": "No available ports in range 8090-9010!", - "cli.error.model_transient": "\ud83d\udd0c The model connection hit a transient error ({error_type}) and didn't recover after auto-retries. This is almost always a VPN/WiFi/provider blip \u2014 just re-run your last prompt. Your session history is intact.", + "cli.error.model_transient": "šŸ”Œ The model connection hit a transient error ({error_type}) and didn't recover after auto-retries. This is almost always a VPN/WiFi/provider blip — just re-run your last prompt. Your session history is intact.", "cli.version.update_disabled": "Update phase disabled because NO_VERSION_UPDATE is set to 1 or true", "cli.model.not_found": "Model '{model}' not found", "cli.model.available": "Available models: {models}", - "cli.model.using": "\ud83c\udfaf Using model: {model}", + "cli.model.using": "šŸŽÆ Using model: {model}", "cli.model.validate_error": "Error validating model: {error}", "cli.agent.not_found": "Agent '{agent}' not found", "cli.agent.available": "Available agents: {agents}", - "cli.agent.using": "\ud83e\udd16 Using agent: {agent}", + "cli.agent.using": "šŸ¤– Using agent: {agent}", "cli.agent.set_error": "Error setting agent: {error}", "cli.agent.cancelling": "Cancelling running agent task...", "cli.agent.task_cancelled": "Agent task cancelled", - "cli.resume.quick_searching": "\ud83d\udd0d Quick Resume selected - finding latest session for {scope}", + "cli.resume.quick_searching": "šŸ” Quick Resume selected - finding latest session for {scope}", "cli.resume.none_found": "No previous session found for this scope; starting fresh.", "cli.resume.available_sessions": "Available sessions: {sessions}", "cli.resume.created": "Created new session: {session}", @@ -47,7 +47,7 @@ "cli.help.commands": "Type /help to view all commands", "cli.help.completion": "Type @ for path completion, or /model to pick a model. Toggle multiline with Alt+M or F2; newline: Shift+Enter.", "cli.help.paste_images": "Paste images: Ctrl+V (even on Mac!), F3, or /paste command.", - "cli.help.macos_paste": "\ud83d\udca1 macOS tip: Use Ctrl+V (not Cmd+V) to paste images in terminal.", + "cli.help.macos_paste": "šŸ’” macOS tip: Use Ctrl+V (not Cmd+V) to paste images in terminal.", "cli.help.cancel_key": "Press {cancel_key} during processing to cancel the current task or inference.", "cli.help.editor_shortcuts": "Use Ctrl+X Ctrl+E to open $EDITOR (Notepad on Windows); Ctrl+X Ctrl+B to background running shell commands; Ctrl+X Ctrl+X to kill running shell commands.", "cli.help.autosave_load": "Use /autosave_load to manually load a previous autosave session.", @@ -56,20 +56,20 @@ "cli.help.shell_passthrough": "! to run shell commands directly (e.g., !git status)", "cli.prompt.enter_task": "Enter your coding task:", "cli.initial_command.processing": "Processing initial command: {command}", - "cli.initial_command.continuing": "\ud83d\udc36 Continuing in Interactive Mode", + "cli.initial_command.continuing": "🐶 Continuing in Interactive Mode", "cli.initial_command.preserved": "Your command and response are preserved in the conversation history.", "cli.initial_command.error": "Error processing initial command: {error}", "cli.prompt_toolkit.installing": "Warning: prompt_toolkit not installed. Installing now...", "cli.prompt_toolkit.installed": "Successfully installed prompt_toolkit", "cli.prompt_toolkit.install_error": "Error installing prompt_toolkit: {error}", "cli.prompt_toolkit.fallback": "Falling back to basic input without tab completion", - "cli.queue.running": "\u23ed running queued prompt", + "cli.queue.running": "ā­ running queued prompt", "cli.input.cancelled": "Input cancelled", "cli.goodbye_ctrld": "Goodbye! (Ctrl+D)", "cli.goodbye": "Goodbye!", "cli.command.error": "Command error: {error}", "cli.autosave.load_cancelled": "Autosave load cancelled", - "cli.autosave.loaded": "\u2705 Autosave loaded: {messages} messages ({tokens} tokens)", + "cli.autosave.loaded": "āœ… Autosave loaded: {messages} messages ({tokens} tokens)", "cli.autosave.load_failed": "Failed to load autosave: {error}", "cli.autosave.headless_unsupported": "/autosave_load requires interactive mode; use -r SESSION in headless mode.", "cli.turn.cancelled": "Cancelled", @@ -83,7 +83,7 @@ "cli.headless.cancelled": "Execution cancelled by user", "cli.headless.error": "Error executing prompt: {error}", "cli.headless.save_failed": "Failed to save session {session}: {error}", - "cli.autosave.loaded_path": "\ud83d\udcc1 From: {path}", + "cli.autosave.loaded_path": "šŸ“ From: {path}", "cfg.set.key_required": "You must supply a key.", "cfg.set.yolo_config_unchanged": "Using YOLO mode from puppy.cfg; configuration unchanged.", "cfg.set.success": "Set {key} = \"{value}\" in puppy.cfg!", @@ -106,9 +106,9 @@ "cfg.unpin.reload_failed": "Model unpinned but reload failed: {error}", "cfg.unpin.failed": "Failed to unpin model from agent '{agent}': {error}", "cfg.diff.apply_failed": "Failed to apply diff settings: {error}", - "cfg.colors.saved": "Banner colors saved! \ud83c\udfa8", + "cfg.colors.saved": "Banner colors saved! šŸŽØ", "cfg.colors.apply_failed": "Failed to apply banner color settings: {error}", - "cfg.colors.all_available": "\ud83c\udfa8 All Available Rich Colors:", + "cfg.colors.all_available": "šŸŽØ All Available Rich Colors:", "cfg.colors.usage": "Usage: /diff {{color_type}} ", "cfg.colors.white_text_note": "All diffs use white text on your chosen background colors", "cfg.colors.hex_note": "You can also use hex colors like #ff0000 or rgb(255,0,0)", @@ -123,5 +123,40 @@ "codex.imagegen.usage": "Usage: /codex-imagegen ", "codex.imagegen.generating": "Generating image with Codex OAuth...", "codex.imagegen.saved": "Generated image saved to {path}", - "codex.logout.reload_failed": "ChatGPT logout: agent reload failed: {error}" + "codex.logout.reload_failed": "ChatGPT logout: agent reload failed: {error}", + "cmd.session.info": "[bold magenta]Autosave Session[/bold magenta]: {name}\nFiles prefix: {prefix}", + "cmd.session.new": "New autosave session: {name}", + "cmd.session.usage": "Usage: /session [id|new]", + "cmd.clear.cleared": "Conversation history cleared!", + "cmd.clear.agent_notice": "The agent will not remember previous interactions.", + "cmd.clear.session_rotated": "Auto-save session rotated to: {id}", + "cmd.clear.clipboard_cleared": "Cleared {count} pending clipboard image(s)", + "cmd.compact.queued": "Compaction requested; it will run before the next model call.", + "cmd.compact.no_history": "No history to compact yet. Ask me something first!", + "cmd.compact.compacting": "šŸ¤” Compacting {count} messages using {strategy} strategy... (~{tokens} tokens)", + "cmd.compact.failed": "Compaction failed. History unchanged.", + "cmd.compact.success.truncation": "✨ Done! History: {before_count} → {after_count} messages using {strategy} strategy\nšŸ¦ Tokens: {before_tokens} → {after_tokens} ({reduction_pct}% reduction)", + "cmd.compact.success.summarization": "✨ Done! History: {before_count} → {after_count} messages via summarization\nšŸ¦ Tokens: {before_tokens} → {after_tokens} ({reduction_pct}% reduction)", + "cmd.compact.error": "/compact error: {error}", + "cmd.truncate.usage": "Usage: /truncate (where N is the number of messages to keep)", + "cmd.truncate.must_be_positive": "N must be a positive integer", + "cmd.truncate.invalid_int": "N must be a valid integer", + "cmd.truncate.no_history": "No history to truncate yet. Ask me something first!", + "cmd.truncate.already_short": "History already has {current} messages, which is <= {n}. Nothing to truncate.", + "cmd.truncate.success": "Truncated message history from {before} to {after} messages (keeping system message and {kept} most recent)", + "cmd.quick_resume.searching": "Quick Resume selected - finding latest session for {scope}", + "cmd.quick_resume.no_session": "No previous session found for this scope; staying in current session.", + "cmd.quick_resume.file_not_found": "Quick-resume session file was not found; staying in current session.", + "cmd.quick_resume.failed": "Quick-resume failed; staying in current session.", + "cmd.quick_resume.success": "Quick resume loaded: {count} messages ({tokens} tokens)", + "cmd.dump_context.usage": "Usage: /dump_context ", + "cmd.dump_context.invalid_name": "Invalid session name: {name}. Session names must be 1-128 chars of [A-Za-z0-9._-] and may not start with 'auto_session_' (reserved).", + "cmd.dump_context.no_history": "No message history to dump!", + "cmd.dump_context.failed": "Failed to dump context: {error}", + "cmd.dump_context.success": "āœ… Context saved: {message_count} messages ({total_tokens} tokens)\nšŸ“ Files: {pickle_path}, {metadata_path}", + "cmd.load_context.usage": "Usage: /load_context ", + "cmd.load_context.not_found": "Context file not found: {path}", + "cmd.load_context.available": "Available contexts: {contexts}", + "cmd.load_context.failed": "Failed to load context: {error}", + "cmd.load_context.success": "āœ… Context loaded: {count} messages ({tokens} tokens)\nšŸ“ From: {path}\nšŸ”„ Autosave rotated to: {session_id} (snapshot at {file} is preserved; further autosaves land in the new session)" } diff --git a/code_puppy/session_lifecycle.py b/code_puppy/session_lifecycle.py index 377529daa..9bc739c5e 100644 --- a/code_puppy/session_lifecycle.py +++ b/code_puppy/session_lifecycle.py @@ -73,7 +73,7 @@ def persist_named_session( *, base_dir: Path, auto_saved: bool = False, - success_message_template: Optional[str] = None, + success_message_key: Optional[str] = None, ) -> SessionMetadata: """Save ``agent.get_message_history()`` under ``session_name`` and fire hooks. @@ -83,14 +83,19 @@ def persist_named_session( ``/dump_context``. The bit is preserved in ``SessionMetadata`` so downstream consumers can filter. - ``success_message_template`` (optional) is a format string with these - available substitutions: ``{message_count}``, ``{total_tokens}``, - ``{pickle_path}``, ``{metadata_path}``, ``{session_name}``. When provided, - the formatted result is emitted via ``emit_success`` so a caller like - ``/dump_context`` can keep its existing user-facing line without - bifurcating the helper. When omitted, no success line is emitted (the - correct behavior for silent save-back paths like ``-r NAME`` and - periodic autosave). + ``success_message_key`` (optional) is an i18n catalog key. When provided, + the helper resolves it via ``t()`` with the following available named + parameters -- ``{message_count}``, ``{total_tokens}``, ``{pickle_path}``, + ``{metadata_path}``, ``{session_name}`` -- and emits the result via + ``emit_success`` so a caller like ``/dump_context`` can keep its + user-facing line without bifurcating the helper. When omitted, no success + line is emitted (the correct behavior for silent save-back paths like + ``-r NAME`` and periodic autosave). + + Passing a catalog *key* (not a raw template) is deliberate: catalog text + is untrusted input and must NEVER be routed through ``str.format`` -- + ``t()`` uses a hardened ``{identifier}``-only interpolator that neither + walks object internals nor honours format specs. See ``docs/I18N.md``. Returns the ``SessionMetadata`` produced by ``save_session`` so callers can surface their own UX as well. @@ -103,27 +108,24 @@ def persist_named_session( token_estimator=agent.estimate_tokens_for_message, auto_saved=auto_saved, ) - if success_message_template is not None: - try: - from code_puppy.messaging import emit_success - - emit_success( - success_message_template.format( - message_count=metadata.message_count, - total_tokens=metadata.total_tokens, - pickle_path=metadata.pickle_path, - metadata_path=metadata.metadata_path, - session_name=session_name, - ) + if success_message_key is not None: + # Safe seam: t() interpolates via the hardened {identifier} grammar, + # so a missing/renamed placeholder leaves the token intact rather + # than raising -- no need for the old defensive try/except that + # str.format required. + from code_puppy.i18n import t + from code_puppy.messaging import emit_success + + emit_success( + t( + success_message_key, + message_count=metadata.message_count, + total_tokens=metadata.total_tokens, + pickle_path=metadata.pickle_path, + metadata_path=metadata.metadata_path, + session_name=session_name, ) - except (KeyError, IndexError, ValueError): - # KeyError: template references an unknown {field}. - # IndexError: positional placeholder out of range. - # ValueError: bad format spec like "{x:!}". All three are bugs - # in the caller-supplied template, not transient failures -- - # swallow so the save path keeps running, but DON'T swallow - # MemoryError / KeyboardInterrupt / etc. - pass + ) # NOTE: deliberately does NOT fire ``fire_post_autosave_callback``. # The ``post_autosave`` hook is reserved for the periodic background # auto-save path (``config.auto_save_session_if_enabled``); firing it diff --git a/tests/i18n/test_session_commands_i18n.py b/tests/i18n/test_session_commands_i18n.py new file mode 100644 index 000000000..782d0d6db --- /dev/null +++ b/tests/i18n/test_session_commands_i18n.py @@ -0,0 +1,163 @@ +"""i18n coverage for session_commands.py extraction. + +Validates cmd.session.*, cmd.clear.*, cmd.compact.*, cmd.truncate.*, +cmd.quick_resume.*, cmd.dump_context.*, and cmd.load_context.* keys. +""" + +import re + +import pytest + +from code_puppy.i18n import catalog, pseudo, translate + +_PLACEHOLDER = re.compile(r"\{(\w+)\}") + +SESSION_PREFIXES = ( + "cmd.session.", + "cmd.clear.", + "cmd.compact.", + "cmd.truncate.", + "cmd.quick_resume.", + "cmd.dump_context.", + "cmd.load_context.", +) + + +@pytest.fixture(autouse=True) +def _reset_locale(): + translate.get_translator().set_locale("en-US") + catalog.reset() + yield + translate.get_translator().set_locale("en-US") + catalog.reset() + + +def _session_keys(): + src = catalog.load_catalog("en-US") + return [k for k in src if any(k.startswith(p) for p in SESSION_PREFIXES)] + + +def test_session_namespace_is_populated(): + assert len(_session_keys()) >= 35 + + +def test_every_session_key_resolves(): + translate.set_locale("en-US") + offenders = [ + k for k in _session_keys() if not translate.t(k) or translate.t(k) == k + ] + assert not offenders, f"session keys not resolving: {offenders}" + + +def test_every_session_key_pseudolocalizes(): + translate.set_locale(pseudo.PSEUDO_LOCALE) + offenders = [k for k in _session_keys() if not translate.t(k).startswith("\u27e6")] + assert not offenders, f"session keys not pseudolocalized: {offenders}" + + +def test_session_keys_interpolate(): + translate.set_locale("en-US") + assert "mysess" in translate.t("cmd.session.info", name="mysess", prefix="/tmp/x") + assert "newsess" in translate.t("cmd.session.new", name="newsess") + + +def test_clear_keys_interpolate(): + translate.set_locale("en-US") + assert "abc123" in translate.t("cmd.clear.session_rotated", id="abc123") + assert "3" in translate.t("cmd.clear.clipboard_cleared", count=3) + + +def test_compact_keys_interpolate(): + translate.set_locale("en-US") + assert "20" in translate.t( + "cmd.compact.compacting", count=20, strategy="summary", tokens="4,000" + ) + truncation_success = translate.t( + "cmd.compact.success.truncation", + before_count=20, + after_count=5, + strategy="truncation", + before_tokens="4,000", + after_tokens="1,000", + reduction_pct="75.0", + ) + assert "truncation" in truncation_success + assert "20" in truncation_success + summarization_success = translate.t( + "cmd.compact.success.summarization", + before_count=20, + after_count=5, + before_tokens="4,000", + after_tokens="1,000", + reduction_pct="75.0", + ) + assert "summarization" in summarization_success + assert "75.0" in summarization_success + assert "boom" in translate.t("cmd.compact.error", error="boom") + + +def test_truncate_keys_interpolate(): + translate.set_locale("en-US") + assert "42" in translate.t("cmd.truncate.already_short", current=42, n=50) + assert "10" in translate.t("cmd.truncate.success", before=15, after=10, kept=9) + + +def test_quick_resume_keys_interpolate(): + translate.set_locale("en-US") + assert "main" in translate.t("cmd.quick_resume.searching", scope="myrepo/main") + assert "7" in translate.t("cmd.quick_resume.success", count=7, tokens=1234) + + +def test_dump_context_keys_interpolate(): + translate.set_locale("en-US") + assert "'bad'" in translate.t("cmd.dump_context.invalid_name", name="'bad'") + assert "boom" in translate.t("cmd.dump_context.failed", error="boom") + success = translate.t( + "cmd.dump_context.success", + message_count=10, + total_tokens=2000, + pickle_path="/tmp/ctx.pkl", + metadata_path="/tmp/ctx.json", + ) + assert "10" in success + assert "/tmp/ctx.pkl" in success + # Load-bearing emoji: the checkmark + folder are part of the /dump_context + # UX. Regressing them silently is what triggered the review feedback the + # first time -- keep them asserted so future extractions can't drop them. + assert "\u2705" in success + assert "\U0001f4c1" in success + + +def test_load_context_keys_interpolate(): + translate.set_locale("en-US") + assert "/tmp/x.pkl" in translate.t("cmd.load_context.not_found", path="/tmp/x.pkl") + assert "a, b" in translate.t("cmd.load_context.available", contexts="a, b") + assert "boom" in translate.t("cmd.load_context.failed", error="boom") + success = translate.t( + "cmd.load_context.success", + count=5, + tokens=1000, + path="/tmp/x.pkl", + session_id="auto_session_123", + file="x.pkl", + ) + assert "5" in success and "auto_session_123" in success + + +def test_no_leftover_placeholders(): + translate.set_locale("en-US") + src = catalog.load_catalog("en-US") + for key in _session_keys(): + entry = src[key] + text = entry if isinstance(entry, str) else entry.get("other", "") + if "{{" in text: + continue + params = {name: "X" for name in _PLACEHOLDER.findall(text)} + rendered = translate.t(key, **params) + assert "{" not in rendered.replace("{{", "").replace("}}", ""), ( + f"{key} left an un-substituted placeholder: {rendered!r}" + ) + + +def test_session_commands_imports_cleanly(): + import code_puppy.command_line.session_commands # noqa: F401 diff --git a/tests/test_session_lifecycle.py b/tests/test_session_lifecycle.py index b8a39ee89..6ae6d088c 100644 --- a/tests/test_session_lifecycle.py +++ b/tests/test_session_lifecycle.py @@ -454,10 +454,16 @@ def test_resolver_accepts_pre_existing_auto_flavored_named_file(self, tmp_path): assert lazy is False -class TestPersistNamedSessionTemplate: - """``success_message_template`` is the /dump_context-vs-silent split.""" +class TestPersistNamedSessionSuccessKey: + """``success_message_key`` is the /dump_context-vs-silent split. - def test_template_omitted_emits_no_success_line(self, tmp_path): + The old API took a raw ``success_message_template`` string that the helper + ran through ``str.format`` -- unsafe for catalog text (see docs/I18N.md). + The new API takes an i18n catalog key that the helper resolves through + the hardened ``t()`` interpolator. + """ + + def test_key_omitted_emits_no_success_line(self, tmp_path): """Periodic autosave / -r save-back path: no user-facing success.""" from unittest.mock import MagicMock, patch @@ -472,12 +478,16 @@ def test_template_omitted_emits_no_success_line(self, tmp_path): mock_success.assert_not_called() - def test_template_present_formats_and_emits(self, tmp_path): - """/dump_context path: explicit success line via template.""" + def test_key_present_resolves_and_emits(self, tmp_path): + """/dump_context path: explicit success line via catalog key.""" from unittest.mock import MagicMock, patch + from code_puppy.i18n import catalog, translate from code_puppy.session_lifecycle import persist_named_session + catalog.reset() + translate.set_locale("en-US") + agent = MagicMock() agent.get_message_history.return_value = [] agent.estimate_tokens_for_message.return_value = 0 @@ -487,18 +497,22 @@ def test_template_present_formats_and_emits(self, tmp_path): agent, "loud_session", base_dir=tmp_path, - success_message_template=( - "saved {message_count} msgs for {session_name}" - ), + success_message_key="cmd.dump_context.success", ) mock_success.assert_called_once() rendered = mock_success.call_args[0][0] - assert "loud_session" in rendered - assert "0 msgs" in rendered + # Params interpolate into the resolved catalog text via the safe + # {identifier}-only grammar in i18n.translate._interpolate. + assert "0 messages" in rendered - def test_template_format_failure_does_not_crash(self, tmp_path): - """A bad template must not poison the save path.""" + def test_unknown_key_does_not_crash(self, tmp_path): + """A missing catalog key must not poison the save path. + + ``t()`` falls back to returning the key itself for a missing entry, + so the emit is harmless but the save must still succeed. This + replaces the old ``str.format``-failure defensive path. + """ from unittest.mock import MagicMock, patch from code_puppy.session_lifecycle import persist_named_session @@ -508,12 +522,10 @@ def test_template_format_failure_does_not_crash(self, tmp_path): agent.estimate_tokens_for_message.return_value = 0 with patch("code_puppy.messaging.emit_success"): - # Template references a nonexistent substitution key; format() - # will raise KeyError, but the helper must swallow it. persist_named_session( agent, "session_x", base_dir=tmp_path, - success_message_template="bad {does_not_exist} field", + success_message_key="cmd.definitely.does.not.exist", ) # If we got here without raising, the contract holds.