aru-code 0.22.0__tar.gz → 0.22.1__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.
- {aru_code-0.22.0/aru_code.egg-info → aru_code-0.22.1}/PKG-INFO +1 -1
- aru_code-0.22.1/aru/__init__.py +1 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/cache_patch.py +3 -3
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/checkpoints.py +189 -189
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/context.py +10 -14
- {aru_code-0.22.0 → aru_code-0.22.1/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.22.0 → aru_code-0.22.1}/pyproject.toml +1 -1
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_checkpoints.py +190 -190
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_context.py +7 -6
- aru_code-0.22.0/aru/__init__.py +0 -1
- {aru_code-0.22.0 → aru_code-0.22.1}/LICENSE +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/README.md +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/agent_factory.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/__init__.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/base.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/executor.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/planner.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/cli.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/commands.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/completers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/config.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/display.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/history_blocks.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/permissions.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/__init__.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/hooks.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/manager.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/providers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/runner.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/runtime.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/session.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/__init__.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/codebase.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/gitignore.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/ranker.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/tasklist.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/SOURCES.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/setup.cfg +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_agents_base.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_base.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_completers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_new.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_session.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_shell.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_codebase.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_config.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_executor.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_gitignore.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_main.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_mcp_client.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_permissions.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_planner.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_plugins.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_providers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_ranker.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.22.1"
|
|
@@ -22,9 +22,9 @@ from __future__ import annotations
|
|
|
22
22
|
# - Protect recent tool results within a token budget
|
|
23
23
|
# - Only prune if there's enough to free (avoid churn)
|
|
24
24
|
# - Walk backwards, protecting recent content first
|
|
25
|
-
#
|
|
26
|
-
_PRUNE_PROTECT_CHARS =
|
|
27
|
-
_PRUNE_MINIMUM_CHARS =
|
|
25
|
+
# Aligned with context.py thresholds to keep context ~30K tokens.
|
|
26
|
+
_PRUNE_PROTECT_CHARS = 55_000 # ~14K tokens — recent content always kept
|
|
27
|
+
_PRUNE_MINIMUM_CHARS = 20_000 # ~5K tokens — only prune if this much is freeable
|
|
28
28
|
_PRUNED_PLACEHOLDER = "[Old tool result cleared]"
|
|
29
29
|
|
|
30
30
|
# Last API call metrics (updated on every internal API call)
|
|
@@ -1,189 +1,189 @@
|
|
|
1
|
-
"""File checkpoint system for undo/rewind support.
|
|
2
|
-
|
|
3
|
-
Tracks file state before tool mutations so changes can be reverted.
|
|
4
|
-
Inspired by Claude Code's fileHistory system.
|
|
5
|
-
|
|
6
|
-
Architecture:
|
|
7
|
-
- Each user message creates a "snapshot" identified by a turn index.
|
|
8
|
-
- Before any file mutation (write_file, edit_file, bash), the pre-edit
|
|
9
|
-
content is saved as a versioned backup in .aru/file-history/{session_id}/.
|
|
10
|
-
- On /undo, the most recent snapshot is applied: files are restored to
|
|
11
|
-
their pre-turn state and the conversation is rewound.
|
|
12
|
-
|
|
13
|
-
Backup naming: {sha256(path)[:16]}@v{version}
|
|
14
|
-
Snapshot: {turn_index: {file_path: BackupEntry}}
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import hashlib
|
|
20
|
-
import os
|
|
21
|
-
import shutil
|
|
22
|
-
import threading
|
|
23
|
-
from dataclasses import dataclass, field
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@dataclass
|
|
27
|
-
class BackupEntry:
|
|
28
|
-
"""A single file backup."""
|
|
29
|
-
backup_path: str | None # None = file didn't exist before this turn
|
|
30
|
-
version: int
|
|
31
|
-
original_path: str
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@dataclass
|
|
35
|
-
class Snapshot:
|
|
36
|
-
"""Checkpoint at a specific conversation turn."""
|
|
37
|
-
turn_index: int
|
|
38
|
-
backups: dict[str, BackupEntry] = field(default_factory=dict) # abs_path → BackupEntry
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
MAX_SNAPSHOTS = 100
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class CheckpointManager:
|
|
45
|
-
"""Manages file checkpoints for a session.
|
|
46
|
-
|
|
47
|
-
Thread-safe: multiple tools may run in parallel within a turn.
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
def __init__(self, session_id: str, base_dir: str | None = None):
|
|
51
|
-
self._session_id = session_id
|
|
52
|
-
self._base_dir = base_dir or os.path.join(os.getcwd(), ".aru", "file-history", session_id)
|
|
53
|
-
self._lock = threading.Lock()
|
|
54
|
-
self._snapshots: list[Snapshot] = []
|
|
55
|
-
self._current_turn: int = 0
|
|
56
|
-
self._tracked_files: set[str] = set()
|
|
57
|
-
# Per-file version counter (monotonic)
|
|
58
|
-
self._file_versions: dict[str, int] = {}
|
|
59
|
-
self._dir_created = False
|
|
60
|
-
|
|
61
|
-
def _ensure_dir(self):
|
|
62
|
-
if not self._dir_created:
|
|
63
|
-
os.makedirs(self._base_dir, exist_ok=True)
|
|
64
|
-
self._dir_created = True
|
|
65
|
-
|
|
66
|
-
def _backup_filename(self, file_path: str, version: int) -> str:
|
|
67
|
-
path_hash = hashlib.sha256(file_path.encode("utf-8")).hexdigest()[:16]
|
|
68
|
-
return f"{path_hash}@v{version}"
|
|
69
|
-
|
|
70
|
-
def begin_turn(self, turn_index: int):
|
|
71
|
-
"""Start a new turn — creates a fresh snapshot for this turn."""
|
|
72
|
-
with self._lock:
|
|
73
|
-
self._current_turn = turn_index
|
|
74
|
-
# Create snapshot for this turn (backups added lazily as files are edited)
|
|
75
|
-
snapshot = Snapshot(turn_index=turn_index)
|
|
76
|
-
self._snapshots.append(snapshot)
|
|
77
|
-
# Enforce cap
|
|
78
|
-
if len(self._snapshots) > MAX_SNAPSHOTS:
|
|
79
|
-
evicted = self._snapshots.pop(0)
|
|
80
|
-
self._cleanup_snapshot_backups(evicted)
|
|
81
|
-
|
|
82
|
-
def track_edit(self, file_path: str):
|
|
83
|
-
"""Capture pre-edit state of a file before mutation.
|
|
84
|
-
|
|
85
|
-
Call this BEFORE writing/editing a file. If the file was already
|
|
86
|
-
captured in the current turn's snapshot, this is a no-op.
|
|
87
|
-
"""
|
|
88
|
-
abs_path = os.path.abspath(file_path)
|
|
89
|
-
|
|
90
|
-
with self._lock:
|
|
91
|
-
if not self._snapshots:
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
current_snapshot = self._snapshots[-1]
|
|
95
|
-
|
|
96
|
-
# Already tracked in this turn
|
|
97
|
-
if abs_path in current_snapshot.backups:
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
# Increment version
|
|
101
|
-
version = self._file_versions.get(abs_path, 0) + 1
|
|
102
|
-
self._file_versions[abs_path] = version
|
|
103
|
-
self._tracked_files.add(abs_path)
|
|
104
|
-
|
|
105
|
-
# Read file outside lock (IO)
|
|
106
|
-
backup_path = None
|
|
107
|
-
if os.path.isfile(abs_path):
|
|
108
|
-
self._ensure_dir()
|
|
109
|
-
backup_name = self._backup_filename(abs_path, version)
|
|
110
|
-
backup_path = os.path.join(self._base_dir, backup_name)
|
|
111
|
-
try:
|
|
112
|
-
shutil.copy2(abs_path, backup_path)
|
|
113
|
-
except OSError:
|
|
114
|
-
backup_path = None
|
|
115
|
-
|
|
116
|
-
# Commit to snapshot
|
|
117
|
-
with self._lock:
|
|
118
|
-
if not self._snapshots:
|
|
119
|
-
return
|
|
120
|
-
entry = BackupEntry(
|
|
121
|
-
backup_path=backup_path,
|
|
122
|
-
version=version,
|
|
123
|
-
original_path=abs_path,
|
|
124
|
-
)
|
|
125
|
-
self._snapshots[-1].backups[abs_path] = entry
|
|
126
|
-
|
|
127
|
-
def undo_last_turn(self) -> tuple[list[str], int]:
|
|
128
|
-
"""Revert files changed in the most recent snapshot.
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
(list of restored file paths, turn_index that was undone)
|
|
132
|
-
"""
|
|
133
|
-
with self._lock:
|
|
134
|
-
if not self._snapshots:
|
|
135
|
-
return [], 0
|
|
136
|
-
snapshot = self._snapshots.pop()
|
|
137
|
-
|
|
138
|
-
restored = []
|
|
139
|
-
for abs_path, entry in snapshot.backups.items():
|
|
140
|
-
try:
|
|
141
|
-
if entry.backup_path is None:
|
|
142
|
-
# File didn't exist before — delete it
|
|
143
|
-
if os.path.isfile(abs_path):
|
|
144
|
-
os.unlink(abs_path)
|
|
145
|
-
restored.append(abs_path)
|
|
146
|
-
elif os.path.isfile(entry.backup_path):
|
|
147
|
-
# Restore from backup
|
|
148
|
-
shutil.copy2(entry.backup_path, abs_path)
|
|
149
|
-
restored.append(abs_path)
|
|
150
|
-
except OSError:
|
|
151
|
-
pass # best effort
|
|
152
|
-
|
|
153
|
-
return restored, snapshot.turn_index
|
|
154
|
-
|
|
155
|
-
def get_snapshot_count(self) -> int:
|
|
156
|
-
with self._lock:
|
|
157
|
-
return len(self._snapshots)
|
|
158
|
-
|
|
159
|
-
def get_last_snapshot_files(self) -> list[str]:
|
|
160
|
-
"""Return files that would be affected by undo."""
|
|
161
|
-
with self._lock:
|
|
162
|
-
if not self._snapshots:
|
|
163
|
-
return []
|
|
164
|
-
return list(self._snapshots[-1].backups.keys())
|
|
165
|
-
|
|
166
|
-
def _cleanup_snapshot_backups(self, snapshot: Snapshot):
|
|
167
|
-
"""Remove backup files for an evicted snapshot (if not referenced by others)."""
|
|
168
|
-
# Collect all backup paths still referenced
|
|
169
|
-
referenced = set()
|
|
170
|
-
for s in self._snapshots:
|
|
171
|
-
for entry in s.backups.values():
|
|
172
|
-
if entry.backup_path:
|
|
173
|
-
referenced.add(entry.backup_path)
|
|
174
|
-
|
|
175
|
-
# Delete unreferenced backups
|
|
176
|
-
for entry in snapshot.backups.values():
|
|
177
|
-
if entry.backup_path and entry.backup_path not in referenced:
|
|
178
|
-
try:
|
|
179
|
-
os.unlink(entry.backup_path)
|
|
180
|
-
except OSError:
|
|
181
|
-
pass
|
|
182
|
-
|
|
183
|
-
def cleanup(self):
|
|
184
|
-
"""Remove all backup files for this session."""
|
|
185
|
-
try:
|
|
186
|
-
if os.path.isdir(self._base_dir):
|
|
187
|
-
shutil.rmtree(self._base_dir, ignore_errors=True)
|
|
188
|
-
except OSError:
|
|
189
|
-
pass
|
|
1
|
+
"""File checkpoint system for undo/rewind support.
|
|
2
|
+
|
|
3
|
+
Tracks file state before tool mutations so changes can be reverted.
|
|
4
|
+
Inspired by Claude Code's fileHistory system.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- Each user message creates a "snapshot" identified by a turn index.
|
|
8
|
+
- Before any file mutation (write_file, edit_file, bash), the pre-edit
|
|
9
|
+
content is saved as a versioned backup in .aru/file-history/{session_id}/.
|
|
10
|
+
- On /undo, the most recent snapshot is applied: files are restored to
|
|
11
|
+
their pre-turn state and the conversation is rewound.
|
|
12
|
+
|
|
13
|
+
Backup naming: {sha256(path)[:16]}@v{version}
|
|
14
|
+
Snapshot: {turn_index: {file_path: BackupEntry}}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import threading
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class BackupEntry:
|
|
28
|
+
"""A single file backup."""
|
|
29
|
+
backup_path: str | None # None = file didn't exist before this turn
|
|
30
|
+
version: int
|
|
31
|
+
original_path: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Snapshot:
|
|
36
|
+
"""Checkpoint at a specific conversation turn."""
|
|
37
|
+
turn_index: int
|
|
38
|
+
backups: dict[str, BackupEntry] = field(default_factory=dict) # abs_path → BackupEntry
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
MAX_SNAPSHOTS = 100
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CheckpointManager:
|
|
45
|
+
"""Manages file checkpoints for a session.
|
|
46
|
+
|
|
47
|
+
Thread-safe: multiple tools may run in parallel within a turn.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, session_id: str, base_dir: str | None = None):
|
|
51
|
+
self._session_id = session_id
|
|
52
|
+
self._base_dir = base_dir or os.path.join(os.getcwd(), ".aru", "file-history", session_id)
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._snapshots: list[Snapshot] = []
|
|
55
|
+
self._current_turn: int = 0
|
|
56
|
+
self._tracked_files: set[str] = set()
|
|
57
|
+
# Per-file version counter (monotonic)
|
|
58
|
+
self._file_versions: dict[str, int] = {}
|
|
59
|
+
self._dir_created = False
|
|
60
|
+
|
|
61
|
+
def _ensure_dir(self):
|
|
62
|
+
if not self._dir_created:
|
|
63
|
+
os.makedirs(self._base_dir, exist_ok=True)
|
|
64
|
+
self._dir_created = True
|
|
65
|
+
|
|
66
|
+
def _backup_filename(self, file_path: str, version: int) -> str:
|
|
67
|
+
path_hash = hashlib.sha256(file_path.encode("utf-8")).hexdigest()[:16]
|
|
68
|
+
return f"{path_hash}@v{version}"
|
|
69
|
+
|
|
70
|
+
def begin_turn(self, turn_index: int):
|
|
71
|
+
"""Start a new turn — creates a fresh snapshot for this turn."""
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._current_turn = turn_index
|
|
74
|
+
# Create snapshot for this turn (backups added lazily as files are edited)
|
|
75
|
+
snapshot = Snapshot(turn_index=turn_index)
|
|
76
|
+
self._snapshots.append(snapshot)
|
|
77
|
+
# Enforce cap
|
|
78
|
+
if len(self._snapshots) > MAX_SNAPSHOTS:
|
|
79
|
+
evicted = self._snapshots.pop(0)
|
|
80
|
+
self._cleanup_snapshot_backups(evicted)
|
|
81
|
+
|
|
82
|
+
def track_edit(self, file_path: str):
|
|
83
|
+
"""Capture pre-edit state of a file before mutation.
|
|
84
|
+
|
|
85
|
+
Call this BEFORE writing/editing a file. If the file was already
|
|
86
|
+
captured in the current turn's snapshot, this is a no-op.
|
|
87
|
+
"""
|
|
88
|
+
abs_path = os.path.abspath(file_path)
|
|
89
|
+
|
|
90
|
+
with self._lock:
|
|
91
|
+
if not self._snapshots:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
current_snapshot = self._snapshots[-1]
|
|
95
|
+
|
|
96
|
+
# Already tracked in this turn
|
|
97
|
+
if abs_path in current_snapshot.backups:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Increment version
|
|
101
|
+
version = self._file_versions.get(abs_path, 0) + 1
|
|
102
|
+
self._file_versions[abs_path] = version
|
|
103
|
+
self._tracked_files.add(abs_path)
|
|
104
|
+
|
|
105
|
+
# Read file outside lock (IO)
|
|
106
|
+
backup_path = None
|
|
107
|
+
if os.path.isfile(abs_path):
|
|
108
|
+
self._ensure_dir()
|
|
109
|
+
backup_name = self._backup_filename(abs_path, version)
|
|
110
|
+
backup_path = os.path.join(self._base_dir, backup_name)
|
|
111
|
+
try:
|
|
112
|
+
shutil.copy2(abs_path, backup_path)
|
|
113
|
+
except OSError:
|
|
114
|
+
backup_path = None
|
|
115
|
+
|
|
116
|
+
# Commit to snapshot
|
|
117
|
+
with self._lock:
|
|
118
|
+
if not self._snapshots:
|
|
119
|
+
return
|
|
120
|
+
entry = BackupEntry(
|
|
121
|
+
backup_path=backup_path,
|
|
122
|
+
version=version,
|
|
123
|
+
original_path=abs_path,
|
|
124
|
+
)
|
|
125
|
+
self._snapshots[-1].backups[abs_path] = entry
|
|
126
|
+
|
|
127
|
+
def undo_last_turn(self) -> tuple[list[str], int]:
|
|
128
|
+
"""Revert files changed in the most recent snapshot.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
(list of restored file paths, turn_index that was undone)
|
|
132
|
+
"""
|
|
133
|
+
with self._lock:
|
|
134
|
+
if not self._snapshots:
|
|
135
|
+
return [], 0
|
|
136
|
+
snapshot = self._snapshots.pop()
|
|
137
|
+
|
|
138
|
+
restored = []
|
|
139
|
+
for abs_path, entry in snapshot.backups.items():
|
|
140
|
+
try:
|
|
141
|
+
if entry.backup_path is None:
|
|
142
|
+
# File didn't exist before — delete it
|
|
143
|
+
if os.path.isfile(abs_path):
|
|
144
|
+
os.unlink(abs_path)
|
|
145
|
+
restored.append(abs_path)
|
|
146
|
+
elif os.path.isfile(entry.backup_path):
|
|
147
|
+
# Restore from backup
|
|
148
|
+
shutil.copy2(entry.backup_path, abs_path)
|
|
149
|
+
restored.append(abs_path)
|
|
150
|
+
except OSError:
|
|
151
|
+
pass # best effort
|
|
152
|
+
|
|
153
|
+
return restored, snapshot.turn_index
|
|
154
|
+
|
|
155
|
+
def get_snapshot_count(self) -> int:
|
|
156
|
+
with self._lock:
|
|
157
|
+
return len(self._snapshots)
|
|
158
|
+
|
|
159
|
+
def get_last_snapshot_files(self) -> list[str]:
|
|
160
|
+
"""Return files that would be affected by undo."""
|
|
161
|
+
with self._lock:
|
|
162
|
+
if not self._snapshots:
|
|
163
|
+
return []
|
|
164
|
+
return list(self._snapshots[-1].backups.keys())
|
|
165
|
+
|
|
166
|
+
def _cleanup_snapshot_backups(self, snapshot: Snapshot):
|
|
167
|
+
"""Remove backup files for an evicted snapshot (if not referenced by others)."""
|
|
168
|
+
# Collect all backup paths still referenced
|
|
169
|
+
referenced = set()
|
|
170
|
+
for s in self._snapshots:
|
|
171
|
+
for entry in s.backups.values():
|
|
172
|
+
if entry.backup_path:
|
|
173
|
+
referenced.add(entry.backup_path)
|
|
174
|
+
|
|
175
|
+
# Delete unreferenced backups
|
|
176
|
+
for entry in snapshot.backups.values():
|
|
177
|
+
if entry.backup_path and entry.backup_path not in referenced:
|
|
178
|
+
try:
|
|
179
|
+
os.unlink(entry.backup_path)
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
def cleanup(self):
|
|
184
|
+
"""Remove all backup files for this session."""
|
|
185
|
+
try:
|
|
186
|
+
if os.path.isdir(self._base_dir):
|
|
187
|
+
shutil.rmtree(self._base_dir, ignore_errors=True)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
@@ -24,8 +24,8 @@ from __future__ import annotations
|
|
|
24
24
|
# ── Constants ──────────────────────────────────────────────────────
|
|
25
25
|
|
|
26
26
|
# Pruning: minimum chars that must be freeable to justify a prune pass.
|
|
27
|
-
#
|
|
28
|
-
PRUNE_MINIMUM_CHARS =
|
|
27
|
+
# Lower than opencode's 20K tokens to fire early and keep context ~30K tokens.
|
|
28
|
+
PRUNE_MINIMUM_CHARS = 20_000 # ~5K tokens
|
|
29
29
|
# Placeholder that replaces cleared tool_result content. Matches
|
|
30
30
|
# cache_patch.py's _PRUNED_PLACEHOLDER so both layers produce identical
|
|
31
31
|
# text when a tool output is cleared.
|
|
@@ -177,24 +177,20 @@ def _tool_result_content_len(msg: dict) -> int:
|
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
def _get_prune_protect_chars(model_id: str = "default") -> int:
|
|
180
|
-
"""Chars of recent
|
|
180
|
+
"""Chars of recent tool-result content that must NEVER be pruned.
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
Why flat (not scaled by model): opencode validated this in production
|
|
189
|
-
on contexts from 128K to 1M — scaling by ratio adds complexity without
|
|
190
|
-
improving behavior, and protecting too much in 1M-context models can
|
|
191
|
-
actually hurt prompt caching by keeping rarely-touched tail content warm.
|
|
182
|
+
Targets a ~30K token total context window. With ~5K tokens of
|
|
183
|
+
system prompt + tool definitions and ~7K of user/assistant text,
|
|
184
|
+
the tool output budget is ~18K tokens ≈ 65K chars. We protect
|
|
185
|
+
55K chars (~14K tokens) of recent tool output so pruning fires
|
|
186
|
+
at protect + PRUNE_MINIMUM = 55K + 20K = 75K chars (~19K tokens
|
|
187
|
+
of tool output), keeping the steady-state around 30K total.
|
|
192
188
|
|
|
193
189
|
The `model_id` parameter is retained for signature compatibility with
|
|
194
190
|
older call sites; it has no effect on the returned value.
|
|
195
191
|
"""
|
|
196
192
|
del model_id # unused — kept for signature compatibility
|
|
197
|
-
return
|
|
193
|
+
return 55_000
|
|
198
194
|
|
|
199
195
|
|
|
200
196
|
def prune_history(
|
|
@@ -1,190 +1,190 @@
|
|
|
1
|
-
"""Tests for the checkpoint/undo system."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import tempfile
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from aru.checkpoints import CheckpointManager
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
@pytest.fixture
|
|
12
|
-
def tmp_workspace(tmp_path):
|
|
13
|
-
"""Create a temporary workspace with some files."""
|
|
14
|
-
(tmp_path / "hello.py").write_text("print('hello')\n")
|
|
15
|
-
(tmp_path / "config.json").write_text('{"key": "value"}\n')
|
|
16
|
-
return tmp_path
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@pytest.fixture
|
|
20
|
-
def manager(tmp_path):
|
|
21
|
-
"""Create a CheckpointManager with temp backup dir."""
|
|
22
|
-
backup_dir = str(tmp_path / "backups")
|
|
23
|
-
return CheckpointManager("test-session", base_dir=backup_dir)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class TestCheckpointManager:
|
|
27
|
-
def test_begin_turn_creates_snapshot(self, manager):
|
|
28
|
-
manager.begin_turn(1)
|
|
29
|
-
assert manager.get_snapshot_count() == 1
|
|
30
|
-
|
|
31
|
-
def test_track_edit_captures_file_state(self, manager, tmp_workspace):
|
|
32
|
-
manager.begin_turn(1)
|
|
33
|
-
file_path = str(tmp_workspace / "hello.py")
|
|
34
|
-
manager.track_edit(file_path)
|
|
35
|
-
|
|
36
|
-
affected = manager.get_last_snapshot_files()
|
|
37
|
-
assert os.path.abspath(file_path) in affected
|
|
38
|
-
|
|
39
|
-
def test_track_edit_idempotent_within_turn(self, manager, tmp_workspace):
|
|
40
|
-
manager.begin_turn(1)
|
|
41
|
-
file_path = str(tmp_workspace / "hello.py")
|
|
42
|
-
manager.track_edit(file_path)
|
|
43
|
-
manager.track_edit(file_path) # should be no-op
|
|
44
|
-
|
|
45
|
-
affected = manager.get_last_snapshot_files()
|
|
46
|
-
assert len(affected) == 1
|
|
47
|
-
|
|
48
|
-
def test_undo_restores_edited_file(self, manager, tmp_workspace):
|
|
49
|
-
file_path = str(tmp_workspace / "hello.py")
|
|
50
|
-
original_content = "print('hello')\n"
|
|
51
|
-
|
|
52
|
-
manager.begin_turn(1)
|
|
53
|
-
manager.track_edit(file_path)
|
|
54
|
-
|
|
55
|
-
# Simulate edit
|
|
56
|
-
with open(file_path, "w") as f:
|
|
57
|
-
f.write("print('CHANGED')\n")
|
|
58
|
-
assert open(file_path).read() == "print('CHANGED')\n"
|
|
59
|
-
|
|
60
|
-
# Undo
|
|
61
|
-
restored, turn = manager.undo_last_turn()
|
|
62
|
-
assert turn == 1
|
|
63
|
-
assert os.path.abspath(file_path) in restored
|
|
64
|
-
assert open(file_path).read() == original_content
|
|
65
|
-
|
|
66
|
-
def test_undo_deletes_newly_created_file(self, manager, tmp_workspace):
|
|
67
|
-
new_file = str(tmp_workspace / "new_file.py")
|
|
68
|
-
|
|
69
|
-
manager.begin_turn(1)
|
|
70
|
-
manager.track_edit(new_file) # file doesn't exist yet
|
|
71
|
-
|
|
72
|
-
# Simulate creation
|
|
73
|
-
with open(new_file, "w") as f:
|
|
74
|
-
f.write("new content\n")
|
|
75
|
-
assert os.path.isfile(new_file)
|
|
76
|
-
|
|
77
|
-
# Undo should delete the file
|
|
78
|
-
restored, turn = manager.undo_last_turn()
|
|
79
|
-
assert os.path.abspath(new_file) in restored
|
|
80
|
-
assert not os.path.isfile(new_file)
|
|
81
|
-
|
|
82
|
-
def test_undo_multiple_files(self, manager, tmp_workspace):
|
|
83
|
-
file1 = str(tmp_workspace / "hello.py")
|
|
84
|
-
file2 = str(tmp_workspace / "config.json")
|
|
85
|
-
|
|
86
|
-
manager.begin_turn(1)
|
|
87
|
-
manager.track_edit(file1)
|
|
88
|
-
manager.track_edit(file2)
|
|
89
|
-
|
|
90
|
-
# Edit both
|
|
91
|
-
with open(file1, "w") as f:
|
|
92
|
-
f.write("changed1\n")
|
|
93
|
-
with open(file2, "w") as f:
|
|
94
|
-
f.write("changed2\n")
|
|
95
|
-
|
|
96
|
-
# Undo
|
|
97
|
-
restored, _ = manager.undo_last_turn()
|
|
98
|
-
assert len(restored) == 2
|
|
99
|
-
assert open(file1).read() == "print('hello')\n"
|
|
100
|
-
assert open(file2).read() == '{"key": "value"}\n'
|
|
101
|
-
|
|
102
|
-
def test_undo_only_affects_last_turn(self, manager, tmp_workspace):
|
|
103
|
-
file_path = str(tmp_workspace / "hello.py")
|
|
104
|
-
|
|
105
|
-
# Turn 1: edit file
|
|
106
|
-
manager.begin_turn(1)
|
|
107
|
-
manager.track_edit(file_path)
|
|
108
|
-
with open(file_path, "w") as f:
|
|
109
|
-
f.write("turn1\n")
|
|
110
|
-
|
|
111
|
-
# Turn 2: edit file again
|
|
112
|
-
manager.begin_turn(2)
|
|
113
|
-
manager.track_edit(file_path)
|
|
114
|
-
with open(file_path, "w") as f:
|
|
115
|
-
f.write("turn2\n")
|
|
116
|
-
|
|
117
|
-
# Undo turn 2 → should restore to turn1 state
|
|
118
|
-
restored, turn = manager.undo_last_turn()
|
|
119
|
-
assert turn == 2
|
|
120
|
-
assert open(file_path).read() == "turn1\n"
|
|
121
|
-
|
|
122
|
-
# Undo turn 1 → should restore to original
|
|
123
|
-
restored, turn = manager.undo_last_turn()
|
|
124
|
-
assert turn == 1
|
|
125
|
-
assert open(file_path).read() == "print('hello')\n"
|
|
126
|
-
|
|
127
|
-
def test_undo_empty_returns_empty(self, manager):
|
|
128
|
-
restored, turn = manager.undo_last_turn()
|
|
129
|
-
assert restored == []
|
|
130
|
-
assert turn == 0
|
|
131
|
-
|
|
132
|
-
def test_get_last_snapshot_files_empty(self, manager):
|
|
133
|
-
assert manager.get_last_snapshot_files() == []
|
|
134
|
-
|
|
135
|
-
def test_max_snapshots_enforced(self, manager, tmp_workspace):
|
|
136
|
-
file_path = str(tmp_workspace / "hello.py")
|
|
137
|
-
for i in range(105):
|
|
138
|
-
manager.begin_turn(i)
|
|
139
|
-
manager.track_edit(file_path)
|
|
140
|
-
with open(file_path, "w") as f:
|
|
141
|
-
f.write(f"v{i}\n")
|
|
142
|
-
|
|
143
|
-
assert manager.get_snapshot_count() == 100
|
|
144
|
-
|
|
145
|
-
def test_cleanup_removes_backup_dir(self, manager, tmp_workspace):
|
|
146
|
-
file_path = str(tmp_workspace / "hello.py")
|
|
147
|
-
manager.begin_turn(1)
|
|
148
|
-
manager.track_edit(file_path)
|
|
149
|
-
|
|
150
|
-
assert os.path.isdir(manager._base_dir)
|
|
151
|
-
manager.cleanup()
|
|
152
|
-
assert not os.path.isdir(manager._base_dir)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
class TestSessionUndoLastTurn:
|
|
156
|
-
"""Tests for Session.undo_last_turn (conversation history only)."""
|
|
157
|
-
|
|
158
|
-
def test_undo_removes_last_turn(self):
|
|
159
|
-
from aru.session import Session
|
|
160
|
-
session = Session()
|
|
161
|
-
session.add_message("user", "hello")
|
|
162
|
-
session.add_message("assistant", "hi there")
|
|
163
|
-
session.add_message("user", "how are you")
|
|
164
|
-
session.add_message("assistant", "good")
|
|
165
|
-
|
|
166
|
-
removed = session.undo_last_turn()
|
|
167
|
-
assert removed == 2 # user + assistant
|
|
168
|
-
assert len(session.history) == 2
|
|
169
|
-
assert session.history[-1]["role"] == "assistant"
|
|
170
|
-
|
|
171
|
-
def test_undo_removes_tool_messages(self):
|
|
172
|
-
from aru.session import Session
|
|
173
|
-
session = Session()
|
|
174
|
-
session.add_message("user", "fix the bug")
|
|
175
|
-
session.add_message("assistant", "reading file")
|
|
176
|
-
session.add_message("tool", "file contents here")
|
|
177
|
-
session.add_message("assistant", "done")
|
|
178
|
-
|
|
179
|
-
removed = session.undo_last_turn()
|
|
180
|
-
# Should remove: user + assistant + tool + assistant = 4 if they go back to last user
|
|
181
|
-
# Actually: pops from end until user is found
|
|
182
|
-
# done (assistant) → tool → reading file (assistant) → fix the bug (user) = 4
|
|
183
|
-
assert removed == 4
|
|
184
|
-
assert len(session.history) == 0
|
|
185
|
-
|
|
186
|
-
def test_undo_empty_history(self):
|
|
187
|
-
from aru.session import Session
|
|
188
|
-
session = Session()
|
|
189
|
-
removed = session.undo_last_turn()
|
|
190
|
-
assert removed == 0
|
|
1
|
+
"""Tests for the checkpoint/undo system."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from aru.checkpoints import CheckpointManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def tmp_workspace(tmp_path):
|
|
13
|
+
"""Create a temporary workspace with some files."""
|
|
14
|
+
(tmp_path / "hello.py").write_text("print('hello')\n")
|
|
15
|
+
(tmp_path / "config.json").write_text('{"key": "value"}\n')
|
|
16
|
+
return tmp_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def manager(tmp_path):
|
|
21
|
+
"""Create a CheckpointManager with temp backup dir."""
|
|
22
|
+
backup_dir = str(tmp_path / "backups")
|
|
23
|
+
return CheckpointManager("test-session", base_dir=backup_dir)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestCheckpointManager:
|
|
27
|
+
def test_begin_turn_creates_snapshot(self, manager):
|
|
28
|
+
manager.begin_turn(1)
|
|
29
|
+
assert manager.get_snapshot_count() == 1
|
|
30
|
+
|
|
31
|
+
def test_track_edit_captures_file_state(self, manager, tmp_workspace):
|
|
32
|
+
manager.begin_turn(1)
|
|
33
|
+
file_path = str(tmp_workspace / "hello.py")
|
|
34
|
+
manager.track_edit(file_path)
|
|
35
|
+
|
|
36
|
+
affected = manager.get_last_snapshot_files()
|
|
37
|
+
assert os.path.abspath(file_path) in affected
|
|
38
|
+
|
|
39
|
+
def test_track_edit_idempotent_within_turn(self, manager, tmp_workspace):
|
|
40
|
+
manager.begin_turn(1)
|
|
41
|
+
file_path = str(tmp_workspace / "hello.py")
|
|
42
|
+
manager.track_edit(file_path)
|
|
43
|
+
manager.track_edit(file_path) # should be no-op
|
|
44
|
+
|
|
45
|
+
affected = manager.get_last_snapshot_files()
|
|
46
|
+
assert len(affected) == 1
|
|
47
|
+
|
|
48
|
+
def test_undo_restores_edited_file(self, manager, tmp_workspace):
|
|
49
|
+
file_path = str(tmp_workspace / "hello.py")
|
|
50
|
+
original_content = "print('hello')\n"
|
|
51
|
+
|
|
52
|
+
manager.begin_turn(1)
|
|
53
|
+
manager.track_edit(file_path)
|
|
54
|
+
|
|
55
|
+
# Simulate edit
|
|
56
|
+
with open(file_path, "w") as f:
|
|
57
|
+
f.write("print('CHANGED')\n")
|
|
58
|
+
assert open(file_path).read() == "print('CHANGED')\n"
|
|
59
|
+
|
|
60
|
+
# Undo
|
|
61
|
+
restored, turn = manager.undo_last_turn()
|
|
62
|
+
assert turn == 1
|
|
63
|
+
assert os.path.abspath(file_path) in restored
|
|
64
|
+
assert open(file_path).read() == original_content
|
|
65
|
+
|
|
66
|
+
def test_undo_deletes_newly_created_file(self, manager, tmp_workspace):
|
|
67
|
+
new_file = str(tmp_workspace / "new_file.py")
|
|
68
|
+
|
|
69
|
+
manager.begin_turn(1)
|
|
70
|
+
manager.track_edit(new_file) # file doesn't exist yet
|
|
71
|
+
|
|
72
|
+
# Simulate creation
|
|
73
|
+
with open(new_file, "w") as f:
|
|
74
|
+
f.write("new content\n")
|
|
75
|
+
assert os.path.isfile(new_file)
|
|
76
|
+
|
|
77
|
+
# Undo should delete the file
|
|
78
|
+
restored, turn = manager.undo_last_turn()
|
|
79
|
+
assert os.path.abspath(new_file) in restored
|
|
80
|
+
assert not os.path.isfile(new_file)
|
|
81
|
+
|
|
82
|
+
def test_undo_multiple_files(self, manager, tmp_workspace):
|
|
83
|
+
file1 = str(tmp_workspace / "hello.py")
|
|
84
|
+
file2 = str(tmp_workspace / "config.json")
|
|
85
|
+
|
|
86
|
+
manager.begin_turn(1)
|
|
87
|
+
manager.track_edit(file1)
|
|
88
|
+
manager.track_edit(file2)
|
|
89
|
+
|
|
90
|
+
# Edit both
|
|
91
|
+
with open(file1, "w") as f:
|
|
92
|
+
f.write("changed1\n")
|
|
93
|
+
with open(file2, "w") as f:
|
|
94
|
+
f.write("changed2\n")
|
|
95
|
+
|
|
96
|
+
# Undo
|
|
97
|
+
restored, _ = manager.undo_last_turn()
|
|
98
|
+
assert len(restored) == 2
|
|
99
|
+
assert open(file1).read() == "print('hello')\n"
|
|
100
|
+
assert open(file2).read() == '{"key": "value"}\n'
|
|
101
|
+
|
|
102
|
+
def test_undo_only_affects_last_turn(self, manager, tmp_workspace):
|
|
103
|
+
file_path = str(tmp_workspace / "hello.py")
|
|
104
|
+
|
|
105
|
+
# Turn 1: edit file
|
|
106
|
+
manager.begin_turn(1)
|
|
107
|
+
manager.track_edit(file_path)
|
|
108
|
+
with open(file_path, "w") as f:
|
|
109
|
+
f.write("turn1\n")
|
|
110
|
+
|
|
111
|
+
# Turn 2: edit file again
|
|
112
|
+
manager.begin_turn(2)
|
|
113
|
+
manager.track_edit(file_path)
|
|
114
|
+
with open(file_path, "w") as f:
|
|
115
|
+
f.write("turn2\n")
|
|
116
|
+
|
|
117
|
+
# Undo turn 2 → should restore to turn1 state
|
|
118
|
+
restored, turn = manager.undo_last_turn()
|
|
119
|
+
assert turn == 2
|
|
120
|
+
assert open(file_path).read() == "turn1\n"
|
|
121
|
+
|
|
122
|
+
# Undo turn 1 → should restore to original
|
|
123
|
+
restored, turn = manager.undo_last_turn()
|
|
124
|
+
assert turn == 1
|
|
125
|
+
assert open(file_path).read() == "print('hello')\n"
|
|
126
|
+
|
|
127
|
+
def test_undo_empty_returns_empty(self, manager):
|
|
128
|
+
restored, turn = manager.undo_last_turn()
|
|
129
|
+
assert restored == []
|
|
130
|
+
assert turn == 0
|
|
131
|
+
|
|
132
|
+
def test_get_last_snapshot_files_empty(self, manager):
|
|
133
|
+
assert manager.get_last_snapshot_files() == []
|
|
134
|
+
|
|
135
|
+
def test_max_snapshots_enforced(self, manager, tmp_workspace):
|
|
136
|
+
file_path = str(tmp_workspace / "hello.py")
|
|
137
|
+
for i in range(105):
|
|
138
|
+
manager.begin_turn(i)
|
|
139
|
+
manager.track_edit(file_path)
|
|
140
|
+
with open(file_path, "w") as f:
|
|
141
|
+
f.write(f"v{i}\n")
|
|
142
|
+
|
|
143
|
+
assert manager.get_snapshot_count() == 100
|
|
144
|
+
|
|
145
|
+
def test_cleanup_removes_backup_dir(self, manager, tmp_workspace):
|
|
146
|
+
file_path = str(tmp_workspace / "hello.py")
|
|
147
|
+
manager.begin_turn(1)
|
|
148
|
+
manager.track_edit(file_path)
|
|
149
|
+
|
|
150
|
+
assert os.path.isdir(manager._base_dir)
|
|
151
|
+
manager.cleanup()
|
|
152
|
+
assert not os.path.isdir(manager._base_dir)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestSessionUndoLastTurn:
|
|
156
|
+
"""Tests for Session.undo_last_turn (conversation history only)."""
|
|
157
|
+
|
|
158
|
+
def test_undo_removes_last_turn(self):
|
|
159
|
+
from aru.session import Session
|
|
160
|
+
session = Session()
|
|
161
|
+
session.add_message("user", "hello")
|
|
162
|
+
session.add_message("assistant", "hi there")
|
|
163
|
+
session.add_message("user", "how are you")
|
|
164
|
+
session.add_message("assistant", "good")
|
|
165
|
+
|
|
166
|
+
removed = session.undo_last_turn()
|
|
167
|
+
assert removed == 2 # user + assistant
|
|
168
|
+
assert len(session.history) == 2
|
|
169
|
+
assert session.history[-1]["role"] == "assistant"
|
|
170
|
+
|
|
171
|
+
def test_undo_removes_tool_messages(self):
|
|
172
|
+
from aru.session import Session
|
|
173
|
+
session = Session()
|
|
174
|
+
session.add_message("user", "fix the bug")
|
|
175
|
+
session.add_message("assistant", "reading file")
|
|
176
|
+
session.add_message("tool", "file contents here")
|
|
177
|
+
session.add_message("assistant", "done")
|
|
178
|
+
|
|
179
|
+
removed = session.undo_last_turn()
|
|
180
|
+
# Should remove: user + assistant + tool + assistant = 4 if they go back to last user
|
|
181
|
+
# Actually: pops from end until user is found
|
|
182
|
+
# done (assistant) → tool → reading file (assistant) → fix the bug (user) = 4
|
|
183
|
+
assert removed == 4
|
|
184
|
+
assert len(session.history) == 0
|
|
185
|
+
|
|
186
|
+
def test_undo_empty_history(self):
|
|
187
|
+
from aru.session import Session
|
|
188
|
+
session = Session()
|
|
189
|
+
removed = session.undo_last_turn()
|
|
190
|
+
assert removed == 0
|
|
@@ -37,16 +37,17 @@ class TestPruneHistory:
|
|
|
37
37
|
|
|
38
38
|
def test_prunes_old_tool_results_when_over_threshold(self):
|
|
39
39
|
"""Should clear old tool_result content when total tool output
|
|
40
|
-
exceeds protect + minimum (
|
|
40
|
+
exceeds protect + minimum (budget semantics).
|
|
41
41
|
|
|
42
42
|
The budget walks backward over tool_result content chars only.
|
|
43
43
|
Text and tool_use args don't count, so this test uses large
|
|
44
44
|
tool_result payloads to actually trip the prune path.
|
|
45
45
|
"""
|
|
46
|
-
# Three rounds of
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
46
|
+
# Three rounds of tool outputs. Each ~30K chars, total ~90K chars.
|
|
47
|
+
# Entry gate: protect (55K) + minimum (20K) = 75K → 90K exceeds it.
|
|
48
|
+
# Protection budget (55K) covers the most recent block (30K) plus
|
|
49
|
+
# part of the middle, so at least tu_old gets cleared.
|
|
50
|
+
big_output = "line of code\n" * 2_300 # ~30K chars each
|
|
50
51
|
messages = [
|
|
51
52
|
{"role": "user", "content": "round 1"},
|
|
52
53
|
{
|
|
@@ -97,7 +98,7 @@ class TestPruneHistory:
|
|
|
97
98
|
|
|
98
99
|
# The older tool_result must have been cleared — at least one
|
|
99
100
|
# of tu_old/tu_mid should now hold the placeholder, since only
|
|
100
|
-
#
|
|
101
|
+
# 55K chars worth fits inside the protect window.
|
|
101
102
|
cleared_count = sum(
|
|
102
103
|
1 for tu_id in ("tu_old", "tu_mid")
|
|
103
104
|
if by_id[tu_id]["content"] == CLEARED_TOOL_RESULT
|
aru_code-0.22.0/aru/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.22.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|