Update documentation: Add file diff rendering to REPL, bump version to 1.17.0, enhance AGENTS.md, and add test for resume interrupt
This commit is contained in:
@@ -6,3 +6,71 @@
|
|||||||
- Use the `.py` extension for Python source and test files.
|
- Use the `.py` extension for Python source and test files.
|
||||||
- Keep source and test files at the repository top level.
|
- Keep source and test files at the repository top level.
|
||||||
- Name test files `test_*.py`.
|
- Name test files `test_*.py`.
|
||||||
|
|
||||||
|
## Agent Overview
|
||||||
|
|
||||||
|
- `tai`: The main autonomous agent.
|
||||||
|
- `devops`: A bot that manages the DevPlace DevOps platform.
|
||||||
|
|
||||||
|
## Agent Profiles
|
||||||
|
|
||||||
|
- `default`: Default agent profile for general tasks.
|
||||||
|
- `work`: Work-focused agent profile.
|
||||||
|
- `dev`: Development-focused agent profile.
|
||||||
|
|
||||||
|
## Bot Details
|
||||||
|
|
||||||
|
- `devops`: A bot that automates DevPlace DevOps operations.
|
||||||
|
- Manages workspaces, projects, and deployments.
|
||||||
|
- Handles user authentication and access control.
|
||||||
|
- Provides development tools and utilities.
|
||||||
|
- Integrates with OpenShift Dev Spaces.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- Autonomous decision making
|
||||||
|
- Multi-profile support
|
||||||
|
- Background subagent orchestration
|
||||||
|
- Secure vault storage
|
||||||
|
- Container sandbox support
|
||||||
|
- Progress tracking
|
||||||
|
- Interactive REPL
|
||||||
|
- Command-line interface
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
1. Start the main agent:
|
||||||
|
```bash
|
||||||
|
./tai.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Use a specific profile:
|
||||||
|
```bash
|
||||||
|
./tai.py --profile work
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run a one-shot command:
|
||||||
|
```bash
|
||||||
|
./tai.py what is 2+3, use the shell
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Check agent status:
|
||||||
|
```bash
|
||||||
|
./tai.py --version
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Install the agent:
|
||||||
|
```bash
|
||||||
|
./tai.py --install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
- Write clear, concise code
|
||||||
|
- Follow Python conventions
|
||||||
|
- Test thoroughly
|
||||||
|
- Use the agent for documentation
|
||||||
|
- Keep code maintainable
|
||||||
|
- Document decisions
|
||||||
|
- Version control changes
|
||||||
|
- Collaborate effectively
|
||||||
@@ -483,6 +483,12 @@ the terminal width without breaking colors, progress-style output
|
|||||||
keeps its latest segment, and the full text still reaches the model
|
keeps its latest segment, and the full text still reaches the model
|
||||||
for feedback. Workers, pipes, and capture mode stay silent.
|
for feedback. Workers, pipes, and capture mode stay silent.
|
||||||
|
|
||||||
|
File changes render as a unified diff right inside the call box:
|
||||||
|
line numbers, green `+` and red `-` rows on tinted backgrounds,
|
||||||
|
Python syntax colors, and `···` separators between hunks, capped
|
||||||
|
at 120 rows with a hidden-line note. New files show all green,
|
||||||
|
deletes all red, restores diff against current content.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
| Variable | Purpose | Default |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import collections
|
import collections
|
||||||
|
import difflib
|
||||||
import getpass
|
import getpass
|
||||||
import glob
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -36,7 +37,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
readline = None
|
readline = None
|
||||||
|
|
||||||
VERSION = "1.16.0"
|
VERSION = "1.17.0"
|
||||||
WORKER_STEPS = 12
|
WORKER_STEPS = 12
|
||||||
CREATE_SKILL_STEPS = 40
|
CREATE_SKILL_STEPS = 40
|
||||||
FORK_MAX_DEPTH = 2
|
FORK_MAX_DEPTH = 2
|
||||||
@@ -436,6 +437,22 @@ def atomic_write(path, content):
|
|||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def sealed_write(path, content):
|
||||||
|
atomic_write(path, content)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def quarantine(path):
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.replace(path, "%s.corrupt-%d" % (path, int(time.time())))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def release_bump(script_file, part):
|
def release_bump(script_file, part):
|
||||||
with open(script_file, "r", encoding="utf-8") as handle:
|
with open(script_file, "r", encoding="utf-8") as handle:
|
||||||
text = handle.read()
|
text = handle.read()
|
||||||
@@ -697,6 +714,145 @@ def run_live(argv, timeout=SHELL_TIMEOUT, shell=False, cwd=None, env=None, strea
|
|||||||
return proc.returncode, "".join(chunks), time.time() - started, timed_out
|
return proc.returncode, "".join(chunks), time.time() - started, timed_out
|
||||||
|
|
||||||
|
|
||||||
|
DIFF_CONTEXT = 3
|
||||||
|
DIFF_MAX_LINES = 120
|
||||||
|
BG_ADD = "\033[48;5;28m"
|
||||||
|
BG_DEL = "\033[48;5;88m"
|
||||||
|
FG_ORANGE = "\033[38;5;214m"
|
||||||
|
|
||||||
|
PY_KEYWORDS = ("False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield", "match", "case")
|
||||||
|
|
||||||
|
PY_TOKEN_RE = re.compile(r"(?P<string>\"\"\"(?:\\.|[^\\])*?\"\"\"|'''(?:\\.|[^\\])*?'''|\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*')|(?P<comment>#[^\n]*)|(?P<number>\b\d[\d._]*(?:[eE][+-]?\d+)?[jJ]?\b)|(?P<keyword>\b(?:%s)\b)|(?P<decorator>@[A-Za-z_][\w.]*)|(?P<defname>(?<=def )[A-Za-z_]\w*|(?<=class )[A-Za-z_]\w*)" % "|".join(PY_KEYWORDS))
|
||||||
|
|
||||||
|
|
||||||
|
def color_enabled():
|
||||||
|
return not os.environ.get("NO_COLOR") and sys.stdout.isatty()
|
||||||
|
|
||||||
|
|
||||||
|
def highlight_python(line):
|
||||||
|
parts = []
|
||||||
|
pos = 0
|
||||||
|
for match in PY_TOKEN_RE.finditer(line):
|
||||||
|
parts.append(line[pos:match.start()])
|
||||||
|
kind = match.lastgroup
|
||||||
|
text = match.group(0)
|
||||||
|
if kind == "string":
|
||||||
|
parts.append(Ansi.YELLOW + text + Ansi.RESET)
|
||||||
|
elif kind == "comment":
|
||||||
|
parts.append(Ansi.GRAY + text + Ansi.RESET)
|
||||||
|
elif kind == "number":
|
||||||
|
parts.append(FG_ORANGE + text + Ansi.RESET)
|
||||||
|
elif kind == "keyword":
|
||||||
|
parts.append(Ansi.MAGENTA + text + Ansi.RESET)
|
||||||
|
else:
|
||||||
|
parts.append(Ansi.CYAN + text + Ansi.RESET)
|
||||||
|
pos = match.end()
|
||||||
|
parts.append(line[pos:])
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def split_diff_lines(text):
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
lines = text.split("\n")
|
||||||
|
if lines and lines[-1] == "":
|
||||||
|
lines.pop()
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_diff(path, old, new, width=0, colors=True):
|
||||||
|
if new is not None and (old or "") == new:
|
||||||
|
return ""
|
||||||
|
old_lines = split_diff_lines(old or "")
|
||||||
|
new_lines = split_diff_lines(new) if new is not None else []
|
||||||
|
if not width:
|
||||||
|
try:
|
||||||
|
width = shutil.get_terminal_size().columns
|
||||||
|
except OSError:
|
||||||
|
width = 80
|
||||||
|
matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
|
||||||
|
rows = []
|
||||||
|
for group in matcher.get_grouped_opcodes(DIFF_CONTEXT):
|
||||||
|
if rows:
|
||||||
|
rows.append(("gap", 0, 0, ""))
|
||||||
|
for tag, i1, i2, j1, j2 in group:
|
||||||
|
if tag == "equal":
|
||||||
|
for offset in range(i2 - i1):
|
||||||
|
rows.append(("ctx", i1 + offset + 1, j1 + offset + 1, old_lines[i1 + offset]))
|
||||||
|
elif tag == "delete":
|
||||||
|
for offset in range(i2 - i1):
|
||||||
|
rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset]))
|
||||||
|
elif tag == "insert":
|
||||||
|
for offset in range(j2 - j1):
|
||||||
|
rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset]))
|
||||||
|
else:
|
||||||
|
for offset in range(i2 - i1):
|
||||||
|
rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset]))
|
||||||
|
for offset in range(j2 - j1):
|
||||||
|
rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset]))
|
||||||
|
added = sum(1 for kind, _a, _b, _t in rows if kind == "add")
|
||||||
|
removed = sum(1 for kind, _a, _b, _t in rows if kind == "del")
|
||||||
|
if new is None:
|
||||||
|
head = "── %s (deleted, %d lines)" % (path, len(old_lines))
|
||||||
|
elif not old_lines:
|
||||||
|
head = "── %s (new file, %d lines)" % (path, len(new_lines))
|
||||||
|
else:
|
||||||
|
head = "── %s (+%d -%d)" % (path, added, removed)
|
||||||
|
python = str(path).lower().endswith(".py")
|
||||||
|
num_width = max(4, len(str(max(len(old_lines), len(new_lines), 1))))
|
||||||
|
code_width = max(20, width - num_width - 4)
|
||||||
|
fence = None
|
||||||
|
out = []
|
||||||
|
out.append(Ansi.DIM + "│ " + head + Ansi.RESET if colors else "│ " + head)
|
||||||
|
for kind, old_no, new_no, text in rows[:DIFF_MAX_LINES]:
|
||||||
|
if kind == "gap":
|
||||||
|
out.append(Ansi.DIM + "···" + Ansi.RESET if colors else "···")
|
||||||
|
continue
|
||||||
|
code = strip_ansi(text).expandtabs(8)
|
||||||
|
if fence is not None:
|
||||||
|
if python and colors:
|
||||||
|
code = Ansi.YELLOW + code + Ansi.RESET
|
||||||
|
if fence in text:
|
||||||
|
fence = None
|
||||||
|
elif python and colors:
|
||||||
|
for mark in ('"""', "'''"):
|
||||||
|
if text.count(mark) % 2 == 1:
|
||||||
|
fence = mark
|
||||||
|
code = Ansi.YELLOW + code + Ansi.RESET
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
code = highlight_python(code)
|
||||||
|
number = old_no if kind in ("ctx", "del") else new_no
|
||||||
|
gutter = str(number).rjust(num_width)
|
||||||
|
if not colors:
|
||||||
|
sign = {"ctx": " ", "del": "-", "add": "+"}[kind]
|
||||||
|
out.append("%s %s %s" % (gutter, sign, code[:code_width]))
|
||||||
|
continue
|
||||||
|
dimmed = Ansi.DIM + gutter + Ansi.RESET
|
||||||
|
if kind == "ctx":
|
||||||
|
out.append("%s %s" % (dimmed, truncate_ansi(code, code_width)))
|
||||||
|
elif kind == "del":
|
||||||
|
row = "%s %s %s" % (dimmed, Ansi.RED + Ansi.BOLD + "-" + Ansi.RESET, truncate_ansi(code, code_width))
|
||||||
|
out.append(pad_tinted(style_span(row, BG_DEL), width))
|
||||||
|
else:
|
||||||
|
row = "%s %s %s" % (dimmed, Ansi.GREEN + Ansi.BOLD + "+" + Ansi.RESET, truncate_ansi(code, code_width))
|
||||||
|
out.append(pad_tinted(style_span(row, BG_ADD), width))
|
||||||
|
if len(rows) > DIFF_MAX_LINES:
|
||||||
|
note = "··· %d more lines hidden" % (len(rows) - DIFF_MAX_LINES)
|
||||||
|
out.append(Ansi.DIM + note + Ansi.RESET if colors else note)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def pad_tinted(line, width):
|
||||||
|
plain = len(ANSI_RE.sub("", line))
|
||||||
|
if plain < width:
|
||||||
|
fill = " " * (width - plain)
|
||||||
|
if line.endswith(Ansi.RESET):
|
||||||
|
return line[:-len(Ansi.RESET)] + fill + Ansi.RESET
|
||||||
|
return line + fill
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
||||||
MD_BULLETS = ("•", "◦", "▪")
|
MD_BULLETS = ("•", "◦", "▪")
|
||||||
|
|
||||||
@@ -2100,17 +2256,23 @@ class Store:
|
|||||||
|
|
||||||
def load_system(self, name):
|
def load_system(self, name):
|
||||||
path = self.profile_path(name)
|
path = self.profile_path(name)
|
||||||
if os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as handle:
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
return self.unseal_file(handle.read())
|
return self.unseal_file(handle.read())
|
||||||
return None
|
except (OSError, ValueError):
|
||||||
|
quarantine(path)
|
||||||
|
return None
|
||||||
|
except SealError:
|
||||||
|
if not self.seal.enabled:
|
||||||
|
raise
|
||||||
|
quarantine(path)
|
||||||
|
return None
|
||||||
|
|
||||||
def save_system(self, name, text):
|
def save_system(self, name, text):
|
||||||
path = self.profile_path(name)
|
|
||||||
with open(path, "w", encoding="utf-8") as handle:
|
|
||||||
handle.write(self.seal.lock(text))
|
|
||||||
try:
|
try:
|
||||||
os.chmod(path, 0o600)
|
sealed_write(self.profile_path(name), self.seal.lock(text))
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2124,14 +2286,46 @@ class Store:
|
|||||||
data = json.loads(self.unseal_file(raw))
|
data = json.loads(self.unseal_file(raw))
|
||||||
return [item for item in data if isinstance(item, dict) and item.get("role") in ("user", "assistant", "tool")]
|
return [item for item in data if isinstance(item, dict) and item.get("role") in ("user", "assistant", "tool")]
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
quarantine(path)
|
||||||
|
return []
|
||||||
|
except SealError:
|
||||||
|
if not self.seal.enabled:
|
||||||
|
raise
|
||||||
|
quarantine(path)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def save_session(self, name, messages, bot="main"):
|
def save_session(self, name, messages, bot="main"):
|
||||||
path = self.session_path(name) if bot == "main" else self.bot_session_path(name, bot)
|
path = self.session_path(name) if bot == "main" else self.bot_session_path(name, bot)
|
||||||
try:
|
try:
|
||||||
with open(path, "w", encoding="utf-8") as handle:
|
sealed_write(path, self.seal.lock(json.dumps([item for item in messages if item.get("role") != "system"])))
|
||||||
handle.write(self.seal.lock(json.dumps([item for item in messages if item.get("role") != "system"])))
|
except OSError:
|
||||||
os.chmod(path, 0o600)
|
pass
|
||||||
|
|
||||||
|
def turn_state_path(self, profile, bot="main"):
|
||||||
|
if bot == "main":
|
||||||
|
return os.path.join(self.config.profiles_dir, "%s.turn.json" % profile)
|
||||||
|
return os.path.join(self.config.profiles_dir, "%s.bot.%s.turn.json" % (profile, bot))
|
||||||
|
|
||||||
|
def load_turn_state(self, profile, bot="main"):
|
||||||
|
path = self.turn_state_path(profile, bot)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
data = json.loads(self.unseal_file(handle.read()))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
quarantine(path)
|
||||||
|
return {}
|
||||||
|
except SealError:
|
||||||
|
if not self.seal.enabled:
|
||||||
|
raise
|
||||||
|
quarantine(path)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_turn_state(self, profile, bot, state):
|
||||||
|
try:
|
||||||
|
sealed_write(self.turn_state_path(profile, bot), self.seal.lock(json.dumps(state)))
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2154,13 +2348,17 @@ class Store:
|
|||||||
bots = data.get("bots") if isinstance(data, dict) else None
|
bots = data.get("bots") if isinstance(data, dict) else None
|
||||||
return bots if isinstance(bots, dict) else {}
|
return bots if isinstance(bots, dict) else {}
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
quarantine(path)
|
||||||
|
return {}
|
||||||
|
except SealError:
|
||||||
|
if not self.seal.enabled:
|
||||||
|
raise
|
||||||
|
quarantine(path)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def save_bots(self, profile, bots):
|
def save_bots(self, profile, bots):
|
||||||
try:
|
try:
|
||||||
with open(self.bots_registry_path(profile), "w", encoding="utf-8") as handle:
|
sealed_write(self.bots_registry_path(profile), self.seal.lock(json.dumps({"bots": bots})))
|
||||||
handle.write(self.seal.lock(json.dumps({"bots": bots})))
|
|
||||||
os.chmod(self.bots_registry_path(profile), 0o600)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2187,17 +2385,23 @@ class Store:
|
|||||||
if bot == "main":
|
if bot == "main":
|
||||||
return self.load_system(profile)
|
return self.load_system(profile)
|
||||||
path = self.bot_system_path(profile, bot)
|
path = self.bot_system_path(profile, bot)
|
||||||
if os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as handle:
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
return self.unseal_file(handle.read())
|
return self.unseal_file(handle.read())
|
||||||
return None
|
except (OSError, ValueError):
|
||||||
|
quarantine(path)
|
||||||
|
return None
|
||||||
|
except SealError:
|
||||||
|
if not self.seal.enabled:
|
||||||
|
raise
|
||||||
|
quarantine(path)
|
||||||
|
return None
|
||||||
|
|
||||||
def save_bot_system(self, profile, bot, text):
|
def save_bot_system(self, profile, bot, text):
|
||||||
path = self.bot_system_path(profile, bot)
|
|
||||||
with open(path, "w", encoding="utf-8") as handle:
|
|
||||||
handle.write(self.seal.lock(text))
|
|
||||||
try:
|
try:
|
||||||
os.chmod(path, 0o600)
|
sealed_write(self.bot_system_path(profile, bot), self.seal.lock(text))
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2914,6 +3118,10 @@ class Tools:
|
|||||||
self.secret_grants = set()
|
self.secret_grants = set()
|
||||||
self.read_files = set()
|
self.read_files = set()
|
||||||
self.live = False
|
self.live = False
|
||||||
|
self.diff_preview = None
|
||||||
|
|
||||||
|
def note_diff(self, path, old, new):
|
||||||
|
self.diff_preview = {"path": path, "old": old, "new": new}
|
||||||
|
|
||||||
def box_engine(self):
|
def box_engine(self):
|
||||||
try:
|
try:
|
||||||
@@ -3113,7 +3321,7 @@ class Tools:
|
|||||||
return "error: no such file"
|
return "error: no such file"
|
||||||
return truncate(done.stdout.decode("utf-8", "replace"), 8000, 2000)
|
return truncate(done.stdout.decode("utf-8", "replace"), 8000, 2000)
|
||||||
|
|
||||||
def box_write(self, path, content, old=_UNSET, action="write", message=""):
|
def box_write(self, path, content, old=_UNSET, action="write", message="", preview=True):
|
||||||
engine, failure = self.box_engine()
|
engine, failure = self.box_engine()
|
||||||
if not engine:
|
if not engine:
|
||||||
return failure
|
return failure
|
||||||
@@ -3138,6 +3346,8 @@ class Tools:
|
|||||||
self.read_files.add(("box", path))
|
self.read_files.add(("box", path))
|
||||||
audited = self.audit_file(action, "sandbox:" + path, old, content, message, (), old_size, None)
|
audited = self.audit_file(action, "sandbox:" + path, old, content, message, (), old_size, None)
|
||||||
note = "" if audited is not False else " (audit failed)"
|
note = "" if audited is not False else " (audit failed)"
|
||||||
|
if preview and old_size is None:
|
||||||
|
self.note_diff(path, old, content)
|
||||||
return "wrote %s%s" % (path, note)
|
return "wrote %s%s" % (path, note)
|
||||||
|
|
||||||
def box_old_image(self, path):
|
def box_old_image(self, path):
|
||||||
@@ -3172,6 +3382,8 @@ class Tools:
|
|||||||
return "error: " + short_error(exc)
|
return "error: " + short_error(exc)
|
||||||
audited = self.audit_file("write", os.path.abspath(path), old, content, "", (), old_size, None)
|
audited = self.audit_file("write", os.path.abspath(path), old, content, "", (), old_size, None)
|
||||||
note = "" if audited is not False else " (audit failed)"
|
note = "" if audited is not False else " (audit failed)"
|
||||||
|
if old_size is None:
|
||||||
|
self.note_diff(path, old, content)
|
||||||
return "wrote %s%s" % (path, note)
|
return "wrote %s%s" % (path, note)
|
||||||
|
|
||||||
def run_edit_file(self, args):
|
def run_edit_file(self, args):
|
||||||
@@ -3200,6 +3412,7 @@ class Tools:
|
|||||||
return "error: " + short_error(exc)
|
return "error: " + short_error(exc)
|
||||||
audited = self.audit_file("edit", os.path.abspath(path), content, updated)
|
audited = self.audit_file("edit", os.path.abspath(path), content, updated)
|
||||||
note = "" if audited is not False else " (audit failed)"
|
note = "" if audited is not False else " (audit failed)"
|
||||||
|
self.note_diff(path, content, updated)
|
||||||
return "edited %s%s" % (path, note)
|
return "edited %s%s" % (path, note)
|
||||||
|
|
||||||
def run_web_search(self, args):
|
def run_web_search(self, args):
|
||||||
@@ -3864,6 +4077,8 @@ class Tools:
|
|||||||
self.read_files.discard(("home", os.path.abspath(path)))
|
self.read_files.discard(("home", os.path.abspath(path)))
|
||||||
audited = self.audit_file("delete", os.path.abspath(path), old, None, "", (), old_size, None)
|
audited = self.audit_file("delete", os.path.abspath(path), old, None, "", (), old_size, None)
|
||||||
note = "" if audited is not False else " (audit failed)"
|
note = "" if audited is not False else " (audit failed)"
|
||||||
|
if old_size is None:
|
||||||
|
self.note_diff(path, old, None)
|
||||||
return "deleted %s%s" % (path, note)
|
return "deleted %s%s" % (path, note)
|
||||||
|
|
||||||
def box_delete(self, path):
|
def box_delete(self, path):
|
||||||
@@ -3890,6 +4105,8 @@ class Tools:
|
|||||||
self.read_files.discard(("box", path))
|
self.read_files.discard(("box", path))
|
||||||
audited = self.audit_file("delete", "sandbox:" + path, old, None, "", (), old_size, None)
|
audited = self.audit_file("delete", "sandbox:" + path, old, None, "", (), old_size, None)
|
||||||
note = "" if audited is not False else " (audit failed)"
|
note = "" if audited is not False else " (audit failed)"
|
||||||
|
if old_size is None:
|
||||||
|
self.note_diff(path, old, None)
|
||||||
return "deleted %s%s" % (path, note)
|
return "deleted %s%s" % (path, note)
|
||||||
|
|
||||||
def run_audit(self, args):
|
def run_audit(self, args):
|
||||||
@@ -3941,6 +4158,10 @@ class Tools:
|
|||||||
message = "restored from audit #%d (%s-image)" % (row_id, which)
|
message = "restored from audit #%d (%s-image)" % (row_id, which)
|
||||||
if row["path"].startswith("sandbox:"):
|
if row["path"].startswith("sandbox:"):
|
||||||
return self.box_restore(row["path"][len("sandbox:"):], target, message)
|
return self.box_restore(row["path"][len("sandbox:"):], target, message)
|
||||||
|
if os.path.isfile(row["path"]):
|
||||||
|
current, capped = read_capped(row["path"])
|
||||||
|
else:
|
||||||
|
current, capped = None, None
|
||||||
try:
|
try:
|
||||||
parent = os.path.dirname(row["path"])
|
parent = os.path.dirname(row["path"])
|
||||||
if parent:
|
if parent:
|
||||||
@@ -3952,12 +4173,17 @@ class Tools:
|
|||||||
return "error: " + short_error(exc)
|
return "error: " + short_error(exc)
|
||||||
self.audit_file("restore", row["path"], None, target, message)
|
self.audit_file("restore", row["path"], None, target, message)
|
||||||
self.app.store.upsert_file_record(row["path"], target, None, self.active_profile)
|
self.app.store.upsert_file_record(row["path"], target, None, self.active_profile)
|
||||||
|
if capped is None:
|
||||||
|
self.note_diff(row["path"], current, target)
|
||||||
return "restored %s from audit #%d (%s-image)" % (row["path"], row_id, which)
|
return "restored %s from audit #%d (%s-image)" % (row["path"], row_id, which)
|
||||||
|
|
||||||
def box_restore(self, path, target, message):
|
def box_restore(self, path, target, message):
|
||||||
self.read_files.add(("box", path))
|
self.read_files.add(("box", path))
|
||||||
written = self.box_write(path, target, old=None, action="restore", message=message)
|
old, capped = self.box_old_image(path)
|
||||||
|
written = self.box_write(path, target, old=None, action="restore", message=message, preview=False)
|
||||||
if written.startswith("wrote "):
|
if written.startswith("wrote "):
|
||||||
|
if capped is None:
|
||||||
|
self.note_diff(path, old, target)
|
||||||
return "restored sandbox:%s (%s)" % (path, message)
|
return "restored sandbox:%s (%s)" % (path, message)
|
||||||
return written
|
return written
|
||||||
|
|
||||||
@@ -4079,6 +4305,10 @@ class Agent:
|
|||||||
self.timed_out = False
|
self.timed_out = False
|
||||||
self.runner_override = None
|
self.runner_override = None
|
||||||
self.user_stepped_in = False
|
self.user_stepped_in = False
|
||||||
|
self._turn_open = False
|
||||||
|
self._turn_goal = ""
|
||||||
|
self._turn_steps = 0
|
||||||
|
self._turn_tools = 0
|
||||||
self.switch_profile(config.profile, silent=True)
|
self.switch_profile(config.profile, silent=True)
|
||||||
|
|
||||||
def apply_system(self):
|
def apply_system(self):
|
||||||
@@ -4200,8 +4430,34 @@ class Agent:
|
|||||||
def show_result(self, result, elapsed_ms):
|
def show_result(self, result, elapsed_ms):
|
||||||
if self.quiet:
|
if self.quiet:
|
||||||
return
|
return
|
||||||
|
self.show_diff()
|
||||||
print(paint("└─ %d chars · %dms" % (len(result), elapsed_ms), Ansi.DIM))
|
print(paint("└─ %d chars · %dms" % (len(result), elapsed_ms), Ansi.DIM))
|
||||||
|
|
||||||
|
def show_diff(self):
|
||||||
|
preview = getattr(self.tools, "diff_preview", None)
|
||||||
|
if preview is None:
|
||||||
|
return
|
||||||
|
self.tools.diff_preview = None
|
||||||
|
rendered = render_diff(preview["path"], preview["old"], preview["new"], colors=color_enabled())
|
||||||
|
if rendered:
|
||||||
|
print(rendered)
|
||||||
|
|
||||||
|
def checkpoint(self):
|
||||||
|
if not self.persist:
|
||||||
|
return
|
||||||
|
self.store.save_session(self.profile, self.messages, self.bot)
|
||||||
|
if self._turn_open:
|
||||||
|
self.store.save_turn_state(self.profile, self.bot, {"open": True, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": now_iso()})
|
||||||
|
|
||||||
|
def close_turn(self):
|
||||||
|
if not self.persist:
|
||||||
|
self._turn_open = False
|
||||||
|
return
|
||||||
|
self.store.save_session(self.profile, self.messages, self.bot)
|
||||||
|
self._turn_open = False
|
||||||
|
if self._turn_goal:
|
||||||
|
self.store.save_turn_state(self.profile, self.bot, {"open": False, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": now_iso()})
|
||||||
|
|
||||||
def run_turn(self, text, capture=False, max_steps=MAX_STEPS, _routed=False):
|
def run_turn(self, text, capture=False, max_steps=MAX_STEPS, _routed=False):
|
||||||
if not _routed:
|
if not _routed:
|
||||||
mentioned = BOT_MENTION_RE.match(text or "")
|
mentioned = BOT_MENTION_RE.match(text or "")
|
||||||
@@ -4214,6 +4470,11 @@ class Agent:
|
|||||||
text = mentioned.group(2)
|
text = mentioned.group(2)
|
||||||
self.messages.append({"role": "user", "content": text})
|
self.messages.append({"role": "user", "content": text})
|
||||||
self.store.log_event(self.profile, "user", "message", self.store.redact(text, self.profile))
|
self.store.log_event(self.profile, "user", "message", self.store.redact(text, self.profile))
|
||||||
|
self._turn_open = True
|
||||||
|
self._turn_goal = text[:500]
|
||||||
|
self._turn_steps = 0
|
||||||
|
self._turn_tools = 0
|
||||||
|
self.checkpoint()
|
||||||
started = time.time()
|
started = time.time()
|
||||||
backends = []
|
backends = []
|
||||||
pieces = []
|
pieces = []
|
||||||
@@ -4250,6 +4511,7 @@ class Agent:
|
|||||||
if loud:
|
if loud:
|
||||||
print(paint("backend error: %s" % exc, Ansi.RED))
|
print(paint("backend error: %s" % exc, Ansi.RED))
|
||||||
self.messages.pop()
|
self.messages.pop()
|
||||||
|
self.checkpoint()
|
||||||
return last_text or "backend error: %s" % exc
|
return last_text or "backend error: %s" % exc
|
||||||
finally:
|
finally:
|
||||||
if spinner is not None:
|
if spinner is not None:
|
||||||
@@ -4316,6 +4578,9 @@ class Agent:
|
|||||||
last_pair = pair
|
last_pair = pair
|
||||||
run_len = 1
|
run_len = 1
|
||||||
nudged = False
|
nudged = False
|
||||||
|
self._turn_steps += 1
|
||||||
|
self._turn_tools += len(step_pairs)
|
||||||
|
self.checkpoint()
|
||||||
if productive:
|
if productive:
|
||||||
stall = 0
|
stall = 0
|
||||||
else:
|
else:
|
||||||
@@ -4342,8 +4607,7 @@ class Agent:
|
|||||||
else:
|
else:
|
||||||
if loud:
|
if loud:
|
||||||
print(paint("step budget exhausted (%d total steps)" % TOTAL_STEP_CAP, Ansi.YELLOW))
|
print(paint("step budget exhausted (%d total steps)" % TOTAL_STEP_CAP, Ansi.YELLOW))
|
||||||
if self.persist:
|
self.close_turn()
|
||||||
self.store.save_session(self.profile, self.messages, self.bot)
|
|
||||||
total = time.time() - started
|
total = time.time() - started
|
||||||
if loud:
|
if loud:
|
||||||
print(paint("tokens≈%d · %s · %.1fs" % (conversation_tokens(self.messages), "+".join(backends), total), Ansi.DIM))
|
print(paint("tokens≈%d · %s · %.1fs" % (conversation_tokens(self.messages), "+".join(backends), total), Ansi.DIM))
|
||||||
@@ -4773,6 +5037,7 @@ def handle_command(agent, text):
|
|||||||
print(paint("use /restore <audit id>", Ansi.RED))
|
print(paint("use /restore <audit id>", Ansi.RED))
|
||||||
return True
|
return True
|
||||||
print(agent.tools.dispatch("restore", json.dumps({"id": row_id})))
|
print(agent.tools.dispatch("restore", json.dumps({"id": row_id})))
|
||||||
|
agent.show_diff()
|
||||||
elif name == "release":
|
elif name == "release":
|
||||||
bits = arg.split(None, 1)
|
bits = arg.split(None, 1)
|
||||||
if len(bits) != 2 or bits[0] not in ("major", "minor", "patch"):
|
if len(bits) != 2 or bits[0] not in ("major", "minor", "patch"):
|
||||||
@@ -5408,6 +5673,7 @@ def build_parser():
|
|||||||
parser.add_argument("--yes", action="store_true")
|
parser.add_argument("--yes", action="store_true")
|
||||||
parser.add_argument("--yolo", action="store_true")
|
parser.add_argument("--yolo", action="store_true")
|
||||||
parser.add_argument("--auto", action="store_true")
|
parser.add_argument("--auto", action="store_true")
|
||||||
|
parser.add_argument("--continue", dest="resume", action="store_true")
|
||||||
parser.add_argument("--env", default=None)
|
parser.add_argument("--env", default=None)
|
||||||
parser.add_argument("--version", action="store_true")
|
parser.add_argument("--version", action="store_true")
|
||||||
parser.add_argument("--telegram", action="store_true")
|
parser.add_argument("--telegram", action="store_true")
|
||||||
@@ -5433,9 +5699,22 @@ def banner(agent):
|
|||||||
print(" %s: %s" % (label, probe_backend(models_url, key)))
|
print(" %s: %s" % (label, probe_backend(models_url, key)))
|
||||||
if not agent.config.devplace_key:
|
if not agent.config.devplace_key:
|
||||||
print(" fallback: no DEVPLACE_API_KEY")
|
print(" fallback: no DEVPLACE_API_KEY")
|
||||||
|
if agent.store.load_turn_state(agent.profile, agent.bot).get("open"):
|
||||||
|
print(paint("interrupted turn available, restart with --continue to pick it up", Ansi.YELLOW))
|
||||||
print(paint("type /help for commands", Ansi.DIM))
|
print(paint("type /help for commands", Ansi.DIM))
|
||||||
|
|
||||||
|
|
||||||
|
def resume_turn(agent):
|
||||||
|
state = agent.store.load_turn_state(agent.profile, agent.bot)
|
||||||
|
if not state.get("open"):
|
||||||
|
print("nothing to resume: last turn for %s/%s completed normally" % (agent.profile, agent.bot))
|
||||||
|
return ""
|
||||||
|
goal = state.get("goal") or ""
|
||||||
|
print("resuming interrupted turn for %s/%s: %d steps, %d tool calls, goal: %s" % (agent.profile, agent.bot, state.get("steps", 0), state.get("tools", 0), goal[:200]))
|
||||||
|
message = "Continue where you left off%s. Do not repeat steps already completed above; carry on with the next step." % (": the interrupted request was: %s" % goal if goal else "")
|
||||||
|
return agent.run_turn(message)
|
||||||
|
|
||||||
|
|
||||||
def repl(agent):
|
def repl(agent):
|
||||||
if readline is not None:
|
if readline is not None:
|
||||||
try:
|
try:
|
||||||
@@ -5469,6 +5748,7 @@ def repl(agent):
|
|||||||
agent.run_turn(text)
|
agent.run_turn(text)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print(paint("\ninterrupted", Ansi.YELLOW))
|
print(paint("\ninterrupted", Ansi.YELLOW))
|
||||||
|
agent.checkpoint()
|
||||||
finally:
|
finally:
|
||||||
scheduler_stop.set()
|
scheduler_stop.set()
|
||||||
if readline is not None:
|
if readline is not None:
|
||||||
@@ -5526,8 +5806,11 @@ def main(argv=None):
|
|||||||
try:
|
try:
|
||||||
agent.run_turn(" ".join(args.prompt))
|
agent.run_turn(" ".join(args.prompt))
|
||||||
finally:
|
finally:
|
||||||
|
agent.checkpoint()
|
||||||
agent.store.close()
|
agent.store.close()
|
||||||
return 0
|
return 0
|
||||||
|
if args.resume and not args.telegram and not args.scheduler:
|
||||||
|
resume_turn(agent)
|
||||||
banner(agent)
|
banner(agent)
|
||||||
try:
|
try:
|
||||||
repl(agent)
|
repl(agent)
|
||||||
|
|||||||
+193
@@ -299,6 +299,9 @@ class FakeSecretApp:
|
|||||||
self.approvals.append(command)
|
self.approvals.append(command)
|
||||||
return self.approve
|
return self.approve
|
||||||
|
|
||||||
|
def show_diff(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SecretsTests(unittest.TestCase):
|
class SecretsTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -2438,6 +2441,196 @@ class WebFetchTests(unittest.TestCase):
|
|||||||
self.assertIn("never curl or wget", tai.DEFAULT_SYSTEM)
|
self.assertIn("never curl or wget", tai.DEFAULT_SYSTEM)
|
||||||
|
|
||||||
|
|
||||||
|
class DiffTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["TAI_HOME"] = self.tmp.name
|
||||||
|
|
||||||
|
class FakeArgs:
|
||||||
|
profile = "t"
|
||||||
|
yes = True
|
||||||
|
|
||||||
|
self.config = tai.Config(FakeArgs())
|
||||||
|
self.store = tai.Store(self.config, tai.Seal(self.config.home, "diff-test-1"))
|
||||||
|
self.app = FakeSecretApp(self.store)
|
||||||
|
self.app.profile = "t"
|
||||||
|
self.tools = tai.Tools(self.app)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.store.close()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
os.environ.pop("TAI_HOME", None)
|
||||||
|
|
||||||
|
def test_highlight_python(self):
|
||||||
|
out = tai.highlight_python("def f(x=1): # hi")
|
||||||
|
self.assertIn(tai.Ansi.MAGENTA + "def" + tai.Ansi.RESET, out)
|
||||||
|
self.assertIn(tai.Ansi.CYAN + "f" + tai.Ansi.RESET, out)
|
||||||
|
self.assertIn(tai.FG_ORANGE + "1" + tai.Ansi.RESET, out)
|
||||||
|
self.assertIn(tai.Ansi.GRAY + "# hi" + tai.Ansi.RESET, out)
|
||||||
|
self.assertEqual(tai.strip_ansi(out), "def f(x=1): # hi")
|
||||||
|
|
||||||
|
def test_highlight_string_with_hash(self):
|
||||||
|
out = tai.highlight_python("x = 'a # b'")
|
||||||
|
self.assertIn(tai.Ansi.YELLOW + "'a # b'" + tai.Ansi.RESET, out)
|
||||||
|
self.assertNotIn(tai.Ansi.GRAY, out)
|
||||||
|
|
||||||
|
def test_render_diff_plain(self):
|
||||||
|
out = tai.render_diff("a.py", "x = 1\n", "x = 2\n", width=60, colors=False)
|
||||||
|
self.assertEqual(out, "│ ── a.py (+1 -1)\n 1 - x = 1\n 1 + x = 2")
|
||||||
|
|
||||||
|
def test_render_diff_colors(self):
|
||||||
|
out = tai.render_diff("a.py", "x = 1\n", "x = 2\n", width=60, colors=True)
|
||||||
|
self.assertIn(tai.BG_ADD, out)
|
||||||
|
self.assertIn(tai.BG_DEL, out)
|
||||||
|
self.assertIn(tai.FG_ORANGE + "2" + tai.Ansi.RESET, out)
|
||||||
|
for line in out.splitlines()[1:]:
|
||||||
|
self.assertEqual(len(tai.strip_ansi(line)), 60)
|
||||||
|
|
||||||
|
def test_render_diff_gap_and_cap(self):
|
||||||
|
old = "".join("line %d\n" % num for num in range(20))
|
||||||
|
new = old.replace("line 0\n", "line zero\n").replace("line 19\n", "line nineteen\n")
|
||||||
|
out = tai.render_diff("a.py", old, new, width=60, colors=False)
|
||||||
|
self.assertIn("···", out)
|
||||||
|
big = "".join("row %d\n" % num for num in range(200))
|
||||||
|
capped = tai.render_diff("a.py", "", big, width=60, colors=False)
|
||||||
|
self.assertIn("more lines hidden", capped)
|
||||||
|
self.assertEqual(len(capped.splitlines()), 1 + tai.DIFF_MAX_LINES + 1)
|
||||||
|
|
||||||
|
def test_render_diff_shapes(self):
|
||||||
|
self.assertEqual(tai.render_diff("x.py", "a\n", "a\n"), "")
|
||||||
|
created = tai.render_diff("x.py", None, "a\nb\n", width=60, colors=False)
|
||||||
|
self.assertIn("(new file, 2 lines)", created.splitlines()[0])
|
||||||
|
removed = tai.render_diff("x.py", "a\nb\n", None, width=60, colors=False)
|
||||||
|
self.assertIn("(deleted, 2 lines)", removed.splitlines()[0])
|
||||||
|
plain = tai.render_diff("notes.txt", "a\n", "b\n", width=60, colors=True)
|
||||||
|
self.assertIn(tai.BG_ADD, plain)
|
||||||
|
self.assertNotIn(tai.Ansi.MAGENTA, plain)
|
||||||
|
self.assertNotIn(tai.Ansi.YELLOW, plain)
|
||||||
|
|
||||||
|
def test_render_diff_docstring_fence(self):
|
||||||
|
out = tai.render_diff("a.py", "", '"""\ndoc body\n"""\nx = 1\n', width=60, colors=True)
|
||||||
|
self.assertIn(tai.Ansi.YELLOW + "doc body" + tai.Ansi.RESET, out)
|
||||||
|
|
||||||
|
def test_tools_note_preview(self):
|
||||||
|
path = os.path.join(self.tmp.name, "note.py")
|
||||||
|
self.assertTrue(self.tools.dispatch("write_file", json.dumps({"path": path, "content": "x = 1\n"})).startswith("wrote "))
|
||||||
|
preview = self.tools.diff_preview
|
||||||
|
self.assertEqual((preview["path"], preview["old"], preview["new"]), (path, None, "x = 1\n"))
|
||||||
|
self.tools.dispatch("read_file", json.dumps({"path": path}))
|
||||||
|
self.assertTrue(self.tools.dispatch("edit_file", json.dumps({"path": path, "find": "x = 1", "replace": "x = 2"})).startswith("edited "))
|
||||||
|
preview = self.tools.diff_preview
|
||||||
|
self.assertEqual((preview["old"], preview["new"]), ("x = 1\n", "x = 2\n"))
|
||||||
|
self.assertTrue(self.tools.dispatch("delete_file", json.dumps({"path": path})).startswith("deleted "))
|
||||||
|
preview = self.tools.diff_preview
|
||||||
|
self.assertEqual((preview["old"], preview["new"]), ("x = 2\n", None))
|
||||||
|
|
||||||
|
def test_show_result_renders_and_clears(self):
|
||||||
|
agent = tai.Agent(self.config, self.store, persist=False, quiet=False)
|
||||||
|
agent.tools.diff_preview = {"path": "a.py", "old": "x = 1\n", "new": "x = 2\n"}
|
||||||
|
with mock.patch("builtins.print") as shown:
|
||||||
|
agent.show_result("ok", 1)
|
||||||
|
agent.show_result("ok", 1)
|
||||||
|
printed = "\n".join(str(call.args[0]) for call in shown.call_args_list)
|
||||||
|
self.assertEqual(printed.count("── a.py"), 1)
|
||||||
|
self.assertEqual(printed.count("└─"), 2)
|
||||||
|
self.assertNotIn("\033", printed)
|
||||||
|
self.assertIsNone(agent.tools.diff_preview)
|
||||||
|
agent.quiet = True
|
||||||
|
agent.tools.diff_preview = {"path": "a.py", "old": "x = 1\n", "new": "x = 2\n"}
|
||||||
|
with mock.patch("builtins.print") as shown:
|
||||||
|
agent.show_result("ok", 1)
|
||||||
|
shown.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class ResumeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["TAI_HOME"] = self.tmp.name
|
||||||
|
|
||||||
|
class FakeArgs:
|
||||||
|
profile = "t"
|
||||||
|
yes = True
|
||||||
|
|
||||||
|
self.config = tai.Config(FakeArgs())
|
||||||
|
self.store = tai.Store(self.config, tai.Seal(self.config.home, "resume-test-1"))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.store.close()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
os.environ.pop("TAI_HOME", None)
|
||||||
|
|
||||||
|
def text_reply(self, text="done"):
|
||||||
|
return {"role": "assistant", "content": text, "reasoning": "", "tool_calls": [], "backend": "x"}
|
||||||
|
|
||||||
|
def test_atomic_session_survives_failed_replace(self):
|
||||||
|
first = [{"role": "user", "content": "first"}]
|
||||||
|
self.store.save_session("t", first)
|
||||||
|
with mock.patch("os.replace", side_effect=OSError("disk full")):
|
||||||
|
self.store.save_session("t", [{"role": "user", "content": "second"}])
|
||||||
|
self.assertEqual([item["content"] for item in self.store.load_session("t")], ["first"])
|
||||||
|
|
||||||
|
def test_corrupt_session_quarantined(self):
|
||||||
|
path = self.store.session_path("t")
|
||||||
|
self.store.save_session("t", [{"role": "user", "content": "good"}])
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("{not json")
|
||||||
|
self.assertEqual(self.store.load_session("t"), [])
|
||||||
|
leftovers = [entry for entry in os.listdir(self.config.profiles_dir) if ".corrupt-" in entry]
|
||||||
|
self.assertEqual(len(leftovers), 1)
|
||||||
|
with open(os.path.join(self.config.profiles_dir, leftovers[0]), encoding="utf-8") as handle:
|
||||||
|
self.assertEqual(handle.read(), "{not json")
|
||||||
|
|
||||||
|
def test_normal_turn_closes_state(self):
|
||||||
|
agent = tai.Agent(self.config, self.store, persist=True, quiet=True)
|
||||||
|
with mock.patch.object(agent.chat, "complete", return_value=self.text_reply()):
|
||||||
|
agent.run_turn("hello", capture=True)
|
||||||
|
state = self.store.load_turn_state("t", "main")
|
||||||
|
self.assertFalse(state["open"])
|
||||||
|
self.assertEqual(state["goal"], "hello")
|
||||||
|
|
||||||
|
def test_crash_resume_end_to_end(self):
|
||||||
|
agent = tai.Agent(self.config, self.store, persist=True, quiet=True)
|
||||||
|
tool_reply = {"role": "assistant", "content": "", "reasoning": "", "tool_calls": [{"id": "1", "name": "shell", "arguments": json.dumps({"command": "echo resume-mark"})}], "backend": "x"}
|
||||||
|
with mock.patch.object(agent.chat, "complete", side_effect=[dict(tool_reply), KeyboardInterrupt()]):
|
||||||
|
with self.assertRaises(KeyboardInterrupt):
|
||||||
|
agent.run_turn("do the thing", capture=True)
|
||||||
|
state = self.store.load_turn_state("t", "main")
|
||||||
|
self.assertTrue(state["open"])
|
||||||
|
self.assertEqual(state["goal"], "do the thing")
|
||||||
|
self.store.close()
|
||||||
|
store2 = tai.Store(self.config, tai.Seal(self.config.home, "resume-test-1"))
|
||||||
|
self.store = store2
|
||||||
|
agent2 = tai.Agent(self.config, store2, persist=True, quiet=True)
|
||||||
|
texts = [item.get("content") or "" for item in agent2.messages]
|
||||||
|
self.assertTrue(any("do the thing" in text for text in texts))
|
||||||
|
self.assertTrue(any("resume-mark" in text for text in texts))
|
||||||
|
with mock.patch.object(agent2.chat, "complete", return_value=self.text_reply("finished")):
|
||||||
|
with mock.patch("builtins.print"):
|
||||||
|
answer = tai.resume_turn(agent2)
|
||||||
|
self.assertEqual(answer, "finished")
|
||||||
|
self.assertFalse(store2.load_turn_state("t", "main")["open"])
|
||||||
|
|
||||||
|
def test_resume_nothing_open(self):
|
||||||
|
agent = tai.Agent(self.config, self.store, persist=True, quiet=True)
|
||||||
|
with mock.patch.object(agent.chat, "complete") as called:
|
||||||
|
with mock.patch("builtins.print") as shown:
|
||||||
|
self.assertEqual(tai.resume_turn(agent), "")
|
||||||
|
called.assert_not_called()
|
||||||
|
printed = "\n".join(str(call.args[0]) for call in shown.call_args_list)
|
||||||
|
self.assertIn("nothing to resume", printed)
|
||||||
|
|
||||||
|
def test_checkpoint_idle_writes_no_state(self):
|
||||||
|
agent = tai.Agent(self.config, self.store, persist=True, quiet=True)
|
||||||
|
agent.checkpoint()
|
||||||
|
self.assertEqual(self.store.load_turn_state("t", "main"), {})
|
||||||
|
self.assertFalse(os.path.exists(self.store.turn_state_path("t", "main")))
|
||||||
|
|
||||||
|
def test_parser_continue(self):
|
||||||
|
args = tai.build_parser().parse_args(["--continue"])
|
||||||
|
self.assertTrue(args.resume)
|
||||||
|
self.assertFalse(tai.build_parser().parse_args([]).resume)
|
||||||
|
|
||||||
|
|
||||||
class DenialTests(unittest.TestCase):
|
class DenialTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
|||||||
Reference in New Issue
Block a user