mini-code-cli 0.1.0.1__tar.gz → 0.1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/PKG-INFO +2 -2
  2. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/README.md +1 -1
  3. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/agent_loop.py +35 -11
  4. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/persistence.py +107 -2
  5. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/ui.py +10 -0
  6. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/PKG-INFO +2 -2
  7. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/SOURCES.txt +1 -0
  8. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/pyproject.toml +1 -1
  9. mini_code_cli-0.1.0.2/tests/test_last_session_pointer.py +218 -0
  10. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_ui.py +18 -0
  11. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/LICENSE +0 -0
  12. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/__init__.py +0 -0
  13. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/config.py +0 -0
  14. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/permissions.py +0 -0
  15. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/plan.py +0 -0
  16. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/prompts.py +0 -0
  17. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/runner.py +0 -0
  18. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/sandbox.py +0 -0
  19. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/shell_safety_utils.py +0 -0
  20. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/skills.py +0 -0
  21. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/__init__.py +0 -0
  22. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/base.py +0 -0
  23. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/builtin.py +0 -0
  24. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/tools.py +0 -0
  25. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/utils.py +0 -0
  26. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/dependency_links.txt +0 -0
  27. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/entry_points.txt +0 -0
  28. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/requires.txt +0 -0
  29. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/top_level.txt +0 -0
  30. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/setup.cfg +0 -0
  31. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_agent_loop.py +0 -0
  32. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_integration.py +0 -0
  33. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_plan.py +0 -0
  34. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_sandboxing.py +0 -0
  35. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_shell_safety.py +0 -0
  36. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_skills.py +0 -0
  37. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_subagents.py +0 -0
  38. {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-code-cli
3
- Version: 0.1.0.1
3
+ Version: 0.1.0.2
4
4
  Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
5
5
  Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -720,7 +720,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
720
720
  - allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
721
721
  - ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
722
722
  - system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
723
- - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session (the one whose history was last modified) from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given.
723
+ - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
724
724
  - plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
725
725
 
726
726
  ### Available tools
@@ -46,7 +46,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
46
46
  - allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
47
47
  - ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
48
48
  - system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
49
- - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session (the one whose history was last modified) from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given.
49
+ - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
50
50
  - plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
51
51
 
52
52
  ### Available tools
@@ -52,6 +52,7 @@ from .persistence import (
52
52
  load_summary_from_dir,
53
53
  load_usage_from_dir,
54
54
  find_last_session_dir,
55
+ save_last_session_pointer,
55
56
  accumulate_usage,
56
57
  generate_unique_id,
57
58
  _restore_dir,
@@ -368,7 +369,15 @@ class AgentSession:
368
369
  restore_session = getattr(self.args, "restore_session", None)
369
370
  self.loaded_last_session_dir = None
370
371
  if getattr(self.args, "load_last_session", False) and not restore_session:
371
- detected = find_last_session_dir(self.cache_dir)
372
+ # Resolve the most recent session: the .mini pointer recorded under
373
+ # --allowed-dir is consulted first (an explicit record of the last
374
+ # session worked in), falling back to the timestamp heuristic over
375
+ # the cache dir.
376
+ detected = find_last_session_dir(
377
+ self.cache_dir,
378
+ allowed_dir=self.allowed_dir,
379
+ use_pointer=True,
380
+ )
372
381
  if detected:
373
382
  restore_session = detected
374
383
  self.loaded_last_session_dir = detected
@@ -585,6 +594,13 @@ class AgentSession:
585
594
  # user can see / copy the id this conversation is being saved under.
586
595
  self._print_session_id()
587
596
 
597
+ # Record this session as the "latest" so a future --load-last-session can
598
+ # resume it without scanning the cache dir. This lives in the top-level
599
+ # loop's own directory (cache_dir/unique_id), and only the interactive
600
+ # run() writes it -- a sub-agent child drives via _drive_auto and would
601
+ # otherwise overwrite the pointer with its own buried path.
602
+ self._save_last_session_pointer()
603
+
588
604
  # Report the outcome of --load-last-session up front, so the user sees
589
605
  # whether we resumed an earlier conversation (under a brand-new id) or
590
606
  # started fresh because no previous session was found.
@@ -616,6 +632,7 @@ class AgentSession:
616
632
  # 1) Get the user's turn: the one-shot preset, or a fresh line.
617
633
  if pending is not None:
618
634
  self.console.rule(title="Preset prompt", char="=", fg="cyan")
635
+ self.console.short_divider()
619
636
  self.console.turn("You", pending, accent="cyan")
620
637
  user_input = pending
621
638
  pending = None
@@ -654,18 +671,24 @@ class AgentSession:
654
671
  self._persist()
655
672
 
656
673
  def _print_session_id(self):
657
- """Print the session's unique id as an assistant-styled message.
674
+ """Print the session's unique id as a minimal "Assistant" turn.
658
675
 
659
- A short, styled announcement (in the assistant accent) so the user can
660
- see and copy the id the conversation is saved under -- the same id shown
661
- in the welcome banner, but rendered as a transcript turn so it is easy
662
- to spot and grep.
676
+ Instead of a banner/header the id is rendered as an assistant-styled
677
+ (green) turn carrying *only* the id in brackets, so the user can see /
678
+ copy the id this conversation is being saved under without the noise of
679
+ the extra "saved under" line.
663
680
  """
664
- self.console.divider()
665
- self.console.turn(
666
- "Session id",
667
- f"{self.unique_id} (saved under {self.cache_dir})",
668
- accent="green")
681
+ self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
682
+
683
+ def _save_last_session_pointer(self):
684
+ """Record this session's directory in --allowed-dir/.mini.
685
+
686
+ Writes the .mini pointer that a later --load-last-session consults first
687
+ (see save_last_session_pointer). Best-effort: never raises, so a pointer
688
+ hiccup can never break the interactive session.
689
+ """
690
+ save_last_session_pointer(self.allowed_dir,
691
+ os.path.join(self.cache_dir, self.unique_id))
669
692
 
670
693
  # -- autonomous drive (used by sub-agents) ------------------------------
671
694
 
@@ -833,6 +856,7 @@ class AgentSession:
833
856
 
834
857
  def _read_user_line(self):
835
858
  """Read one turn from the user. Returns the text to send, or None to quit."""
859
+ self.console.short_divider()
836
860
  user_input = self.console.read_line(style_line_prompt("You:"))
837
861
  if user_input is None: # EOF / Ctrl-C at the prompt
838
862
  self.console.note("Thank you. Goodbye! Saving history...", "info")
@@ -24,6 +24,7 @@ raising.
24
24
  import json
25
25
  import os
26
26
  import re
27
+ import time
27
28
 
28
29
  from . import ui
29
30
  from .config import DEFAULT_CACHE_LOCATION
@@ -76,8 +77,112 @@ def generate_unique_id():
76
77
  return f"{timestamp}-{uuid.uuid4().hex[:8]}"
77
78
 
78
79
 
79
- def find_last_session_dir(cache_dir):
80
- """Return the path to the most recently *active* session dir under ``cache_dir``.
80
+ # ---------------------------------------------------------------------------
81
+ # latest-session pointer (.mini)
82
+ # ---------------------------------------------------------------------------
83
+
84
+ # The .mini directory (inside the user's --allowed-dir) holds a tiny pointer to
85
+ # the most recent session. --load-last-session consults this pointer *first*
86
+ # (an explicit opt-in the user's environment recorded) before falling back to
87
+ # the timestamp heuristic in find_last_session_dir().
88
+ #
89
+ # NOTE: .mini is also the skills feature's directory. The pointer is a single
90
+ # dedicated file inside it (LAST_SESSION_FILENAME), so the two never collide and
91
+ # the skills code (which only reads <skill-name>/ + SKILLS.md) is unaffected.
92
+ SKILLS_DIRNAME = ".mini"
93
+ LAST_SESSION_FILENAME = "last_session.json"
94
+
95
+
96
+ def _last_session_dir_store(allowed_dir):
97
+ """Return the .mini directory for ``allowed_dir`` (creating it if needed).
98
+
99
+ Best-effort: never raises. Returns None if the directory cannot be ensured,
100
+ in which case the pointer is simply not consulted/updated.
101
+ """
102
+ if not allowed_dir:
103
+ return None
104
+ store = os.path.join(os.path.abspath(allowed_dir), SKILLS_DIRNAME)
105
+ try:
106
+ os.makedirs(store, exist_ok=True)
107
+ return store
108
+ except OSError as e:
109
+ ui.console.note(f"Could not use {store} for the latest-session pointer: {e}", "warn")
110
+ return None
111
+
112
+
113
+ def save_last_session_pointer(allowed_dir, session_dir):
114
+ """Record ``session_dir`` as the latest session in ``allowed_dir/.mini``.
115
+
116
+ Writes a tiny JSON file (the absolute path plus a timestamp) so a later
117
+ --load-last-session run can resume the most recent session without scanning
118
+ the cache dir. Best-effort: never raises, returns the written path or None.
119
+ """
120
+ if not session_dir:
121
+ return None
122
+ store = _last_session_dir_store(allowed_dir)
123
+ if store is None:
124
+ return None
125
+ payload = {
126
+ "session_dir": os.path.abspath(session_dir),
127
+ "recorded_at": time.time(),
128
+ }
129
+ path = os.path.join(store, LAST_SESSION_FILENAME)
130
+ try:
131
+ with open(path, "w", encoding="utf-8") as f:
132
+ json.dump(payload, f, indent=2)
133
+ return path
134
+ except OSError as e:
135
+ ui.console.note(f"Could not record latest-session pointer: {e}", "warn")
136
+ return None
137
+
138
+
139
+ def load_last_session_pointer(allowed_dir):
140
+ """Read the latest-session pointer recorded under ``allowed_dir/.mini``.
141
+
142
+ Returns the recorded session directory (a path into the cache folder), or
143
+ None if no pointer exists or it cannot be read. Best-effort: a missing or
144
+ malformed pointer simply yields None so the caller falls back to the
145
+ timestamp heuristic.
146
+ """
147
+ if not allowed_dir:
148
+ return None
149
+ store = os.path.join(os.path.abspath(allowed_dir), SKILLS_DIRNAME)
150
+ path = os.path.join(store, LAST_SESSION_FILENAME)
151
+ if not os.path.isfile(path):
152
+ return None
153
+ try:
154
+ with open(path, "r", encoding="utf-8") as f:
155
+ payload = json.load(f)
156
+ except Exception:
157
+ return None
158
+ if not isinstance(payload, dict):
159
+ return None
160
+ session_dir = payload.get("session_dir")
161
+ if not session_dir or not os.path.isdir(session_dir):
162
+ return None
163
+ return session_dir
164
+
165
+
166
+ def find_last_session_dir(cache_dir=None, allowed_dir=None, use_pointer=True):
167
+ """Return the path to the most recently *active* session dir.
168
+
169
+ When ``use_pointer`` and an ``allowed_dir`` are given, the .mini pointer
170
+ recorded by a previous run (see save_last_session_pointer) is consulted
171
+ *first* -- it is an explicit record of the session the user last worked in.
172
+ Only if that yields nothing do we fall back to the timestamp heuristic over
173
+ ``cache_dir`` (find_last_session_dir_by_mtime). Session folders with no
174
+ files are ignored. Never raises: a missing/unreadable cache dir simply
175
+ yields None.
176
+ """
177
+ if use_pointer and allowed_dir:
178
+ pointed = load_last_session_pointer(allowed_dir)
179
+ if pointed:
180
+ return pointed
181
+ return find_last_session_dir_by_mtime(cache_dir)
182
+
183
+
184
+ def find_last_session_dir_by_mtime(cache_dir):
185
+ """Timestamp heuristic for the most recently *active* session dir.
81
186
 
82
187
  "Most recent" is decided by the newest modification time of any file inside
83
188
  each session folder (a session rewrites its ``history_{n}.json`` every step),
@@ -371,6 +371,16 @@ class Console:
371
371
  """
372
372
  self.emit(self.style("-" * self.width, fg="gray", dim=True))
373
373
 
374
+ def short_divider(self, char="-", width=8, fg="gray"):
375
+ """Render a short horizontal delimiter line (not the full width).
376
+
377
+ Used to set off the user's own "You:" turn from the preceding assistant
378
+ reply with a light, unobtrusive rule rather than a full-width divider.
379
+ Defaults to a plain ASCII dash so it renders consistently regardless of
380
+ the console's unicode setting.
381
+ """
382
+ self.emit(self.style(char * width, fg=fg, dim=True))
383
+
374
384
  def clear_screen(self):
375
385
  """Clear the terminal and move the cursor to the top-left.
376
386
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-code-cli
3
- Version: 0.1.0.1
3
+ Version: 0.1.0.2
4
4
  Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
5
5
  Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -720,7 +720,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
720
720
  - allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
721
721
  - ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
722
722
  - system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
723
- - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session (the one whose history was last modified) from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given.
723
+ - load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
724
724
  - plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
725
725
 
726
726
  ### Available tools
@@ -26,6 +26,7 @@ mini_code_cli/subagents/base.py
26
26
  mini_code_cli/subagents/builtin.py
27
27
  tests/test_agent_loop.py
28
28
  tests/test_integration.py
29
+ tests/test_last_session_pointer.py
29
30
  tests/test_plan.py
30
31
  tests/test_sandboxing.py
31
32
  tests/test_shell_safety.py
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "mini-code-cli"
3
3
  # version = { file = "mini_code_cli/__init__.py", attr = "__version__" }
4
- version = "0.1.0.1"
4
+ version = "0.1.0.2"
5
5
  description = "Mini Coding Agent. Allows you to easily experiment with a small coding agent."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.10"
@@ -0,0 +1,218 @@
1
+ """Tests for the `.mini` latest-session pointer used by --load-last-session.
2
+
3
+ A tiny pointer is recorded in `--allowed-dir/.mini/last_session.json` at the
4
+ end of every interactive session. `--load-last-session` consults that pointer
5
+ *first* (an explicit record of the session the user last worked in) and only
6
+ falls back to the timestamp heuristic over the cache dir when there is none.
7
+ """
8
+ import io
9
+ import os
10
+ import sys
11
+ import types
12
+ import tempfile
13
+ import json
14
+ import shutil
15
+ import unittest
16
+ from unittest.mock import patch
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ from mini_code_cli import agent_loop, ui, persistence, skills
21
+
22
+
23
+ def _make_args(**kw):
24
+ a = types.SimpleNamespace(
25
+ url="http://x", model="m", allowed_dir=".", cache_dir=None, api_key=None,
26
+ system_prompt=None, agent_md=None, prompt=None, enable_shell=False,
27
+ ask_permission=False, auto_mode=False, restore_session=None, llm_timeout=30,
28
+ tool_timeout=30, load_last_session=False, verbose=False, max_tokens=None,
29
+ temperature=1.0, plan=False, enable_subagents=False, enable_skills=False,
30
+ sudo=False,
31
+ )
32
+ a.__dict__.update(kw)
33
+ return a
34
+
35
+
36
+ class TestPointerBasics(unittest.TestCase):
37
+ def setUp(self):
38
+ self.tmp = tempfile.TemporaryDirectory()
39
+ self.addCleanup(self.tmp.cleanup)
40
+ self.allowed = self.tmp.name
41
+
42
+ def _session_dir(self, name="sess"):
43
+ d = os.path.join(self.tmp.name, "cache", name)
44
+ os.makedirs(d, exist_ok=True)
45
+ open(os.path.join(d, "history.json"), "w").write("[]")
46
+ return d
47
+
48
+ def test_save_then_load_roundtrip(self):
49
+ sdir = self._session_dir("one")
50
+ path = persistence.save_last_session_pointer(self.allowed, sdir)
51
+ self.assertIsNotNone(path)
52
+ self.assertTrue(os.path.isfile(path))
53
+ payload = json.load(open(path))
54
+ self.assertEqual(payload["session_dir"], os.path.abspath(sdir))
55
+ self.assertIn("recorded_at", payload)
56
+ self.assertEqual(persistence.load_last_session_pointer(self.allowed),
57
+ os.path.abspath(sdir))
58
+
59
+ def test_load_missing_returns_none(self):
60
+ self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
61
+
62
+ def test_load_malformed_returns_none(self):
63
+ persistence.save_last_session_pointer(self.allowed, self._session_dir("x"))
64
+ p = os.path.join(self.allowed, ".mini", persistence.LAST_SESSION_FILENAME)
65
+ open(p, "w").write("not json {[")
66
+ self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
67
+
68
+ def test_load_pointing_to_missing_dir_returns_none(self):
69
+ path = persistence.save_last_session_pointer(self.allowed, self._session_dir("x"))
70
+ shutil.rmtree(os.path.join(self.tmp.name, "cache", "x"))
71
+ self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
72
+
73
+ def test_save_none_is_a_noop(self):
74
+ self.assertIsNone(persistence.save_last_session_pointer(self.allowed, None))
75
+ self.assertIsNone(
76
+ persistence.load_last_session_pointer(self.allowed))
77
+
78
+
79
+ class TestFindLastSessionResolution(unittest.TestCase):
80
+ def setUp(self):
81
+ self.tmp = tempfile.TemporaryDirectory()
82
+ self.addCleanup(self.tmp.cleanup)
83
+ self.allowed = self.tmp.name
84
+ self.cache = os.path.join(self.tmp.name, "cache")
85
+
86
+ def _session_dir(self, name):
87
+ d = os.path.join(self.cache, name)
88
+ os.makedirs(d, exist_ok=True)
89
+ f = os.path.join(d, "history.json")
90
+ open(f, "w").write("[]")
91
+ return d, f
92
+
93
+ def test_pointer_wins_over_newer_mtime(self):
94
+ s1, _ = self._session_dir("older")
95
+ s2, f2 = self._session_dir("newer")
96
+ os.utime(f2, (1e12, 1e12)) # far in the future -> newest by mtime
97
+ # Record a pointer to s1 (the older one).
98
+ persistence.save_last_session_pointer(self.allowed, s1)
99
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
100
+ use_pointer=True)
101
+ self.assertEqual(got, os.path.abspath(s1))
102
+
103
+ def test_falls_back_to_mtime_when_no_pointer(self):
104
+ s1, _ = self._session_dir("older")
105
+ s2, f2 = self._session_dir("newer")
106
+ os.utime(f2, (1e12, 1e12))
107
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
108
+ use_pointer=True)
109
+ self.assertEqual(got, os.path.abspath(s2))
110
+
111
+ def test_use_pointer_false_ignores_pointer(self):
112
+ s1, _ = self._session_dir("older")
113
+ s2, f2 = self._session_dir("newer")
114
+ os.utime(f2, (1e12, 1e12))
115
+ persistence.save_last_session_pointer(self.allowed, s1)
116
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
117
+ use_pointer=False)
118
+ self.assertEqual(got, os.path.abspath(s2))
119
+
120
+ def test_none_when_everything_empty(self):
121
+ os.makedirs(self.cache, exist_ok=True)
122
+ got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
123
+ use_pointer=True)
124
+ self.assertIsNone(got)
125
+
126
+
127
+ class TestPointerAndSkillsCoexist(unittest.TestCase):
128
+ def setUp(self):
129
+ self.tmp = tempfile.TemporaryDirectory()
130
+ self.addCleanup(self.tmp.cleanup)
131
+ self.allowed = self.tmp.name
132
+
133
+ def test_pointer_file_does_not_appear_as_a_skill(self):
134
+ # Use skills so .mini + SKILLS.md exist.
135
+ skills.list_skills(self.allowed)
136
+ # A session records its pointer in the same .mini.
137
+ cache = os.path.join(self.tmp.name, "cache")
138
+ sdir = os.path.join(cache, "abc")
139
+ os.makedirs(sdir); open(os.path.join(sdir, "history.json"), "w").write("[]")
140
+ persistence.save_last_session_pointer(self.allowed, sdir)
141
+ # Skills still work, and the pointer file never shows up as a skill.
142
+ listing = skills.list_skills(allowed_dir=self.allowed)
143
+ self.assertNotIn(persistence.LAST_SESSION_FILENAME, listing)
144
+ self.assertIn("Skills", open(skills.summary_path(self.allowed)).read())
145
+
146
+ def test_create_skill_after_pointer_written(self):
147
+ cache = os.path.join(self.tmp.name, "cache")
148
+ sdir = os.path.join(cache, "abc")
149
+ os.makedirs(sdir); open(os.path.join(sdir, "history.json"), "w").write("[]")
150
+ persistence.save_last_session_pointer(self.allowed, sdir)
151
+ out = skills.create_skill(self.allowed, "greet", "greets")
152
+ self.assertIn("Created", out)
153
+ self.assertIn("greet", skills.list_skills(self.allowed))
154
+
155
+
156
+ class TestPrintSessionIdAndDivider(unittest.TestCase):
157
+ """The interactive run() should show the id as an Assistant turn in brackets,
158
+ and draw a short divider before the user's turn."""
159
+
160
+ def setUp(self):
161
+ self.tmp = tempfile.TemporaryDirectory()
162
+ self.addCleanup(self.tmp.cleanup)
163
+ self.buf = io.StringIO()
164
+ self.console = ui.Console(stream=self.buf, color=False, unicode=False)
165
+
166
+ def _fake_llm(self, session):
167
+ def fake(messages, **kw):
168
+ return "hi there", [], {"prompt_tokens": 1, "completion_tokens": 1,
169
+ "total_tokens": 2}
170
+ return fake
171
+
172
+ def test_session_id_renderedin_assistant_turn_and_pointer_written(self):
173
+ allowed = self.tmp.name
174
+ args = _make_args(cache_dir=os.path.join(self.tmp.name, "cache"),
175
+ allowed_dir=allowed, prompt="hello")
176
+ sess = agent_loop.AgentSession(args, console=self.console)
177
+ uid = sess.unique_id
178
+
179
+ with patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
180
+ patch.object(agent_loop.utils, "call_openai_server", self._fake_llm(sess)), \
181
+ patch.object(self.console, "read_line", side_effect=iter(["/quit"])):
182
+ sess.run(initial_prompt="hello")
183
+
184
+ out = self.buf.getvalue()
185
+ # (1) id rendered as an Assistant turn carrying only the id in brackets.
186
+ self.assertIn(f"[{uid}]", out)
187
+ # It sits on the same line as the 'Assistant' role marker.
188
+ self.assertRegex(out, r"(?m)^> Assistant\n \[%s\]$" % uid)
189
+ # No noisy "saved under" line in the session-id turn.
190
+ self.assertNotIn("saved under", out)
191
+
192
+ # (3) a pointer was written under allowed_dir/.mini.
193
+ ptr = os.path.join(allowed, ".mini", persistence.LAST_SESSION_FILENAME)
194
+ self.assertTrue(os.path.isfile(ptr))
195
+ payload = json.load(open(ptr))
196
+ self.assertEqual(payload["session_dir"],
197
+ os.path.abspath(os.path.join(self.tmp.name, "cache", uid)))
198
+
199
+ def test_short_divider_before_preset_you_turn(self):
200
+ args = _make_args(cache_dir=os.path.join(self.tmp.name, "cache"),
201
+ allowed_dir=self.tmp.name, prompt="hello")
202
+ sess = agent_loop.AgentSession(args, console=self.console)
203
+ with patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
204
+ patch.object(agent_loop.utils, "call_openai_server", self._fake_llm(sess)), \
205
+ patch.object(self.console, "read_line", side_effect=iter(["/quit"])):
206
+ sess.run(initial_prompt="hello")
207
+ out = self.buf.getvalue()
208
+ # The preset "You" turn is preceded by a short divider line.
209
+ lines = out.splitlines()
210
+ idx = next(i for i, l in enumerate(lines) if l.strip() == "> You")
211
+ divider = lines[idx - 1].strip()
212
+ self.assertTrue(set(divider) <= {"-", " "})
213
+ self.assertIn("-", divider)
214
+ self.assertLess(len(divider.strip("-")), 12)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ unittest.main()
@@ -115,6 +115,24 @@ class TestConsoleRendering(unittest.TestCase):
115
115
  self.assertIn("Assistant", text)
116
116
  self.assertIn(" line2", text)
117
117
 
118
+ def test_short_divider_renders_without_ansi(self):
119
+ c = ui.Console(stream=self.buf, color=False)
120
+ c.short_divider()
121
+ out = self.buf.getvalue().rstrip("\n")
122
+ # A short run of dashes, not the full-width divider.
123
+ self.assertTrue(set(out) <= {"-", " "})
124
+ self.assertLess(len(out.strip("-")), 12)
125
+ self.assertIn("-", out)
126
+
127
+ def test_short_divider_shorter_than_full_divider(self):
128
+ c = ui.Console(stream=self.buf, color=False)
129
+ c.short_divider()
130
+ short = len(self.buf.getvalue().rstrip("\n").strip())
131
+ self.buf.seek(0); self.buf.truncate()
132
+ c.divider()
133
+ full = len(self.buf.getvalue().rstrip("\n").strip())
134
+ self.assertLess(short, full)
135
+
118
136
  def test_welcome_renders_without_error(self):
119
137
  c = ui.Console(stream=self.buf, color=False)
120
138
  c.welcome("1.0", "id", "/cache", "/home", "http://x", "model",
File without changes