messygit 0.3.1__tar.gz → 0.3.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.
- messygit-0.3.2/.github/workflows/test.yml +27 -0
- {messygit-0.3.1 → messygit-0.3.2}/PKG-INFO +3 -1
- {messygit-0.3.1 → messygit-0.3.2}/messygit/agent/agent.py +19 -2
- {messygit-0.3.1 → messygit-0.3.2}/messygit/agent/tool.py +8 -4
- messygit-0.3.2/messygit/agent/tools.py +137 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/cli.py +30 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/git.py +45 -1
- {messygit-0.3.1 → messygit-0.3.2}/pyproject.toml +4 -1
- messygit-0.3.2/tests/agent_test.py +245 -0
- messygit-0.3.2/tests/config_test.py +128 -0
- messygit-0.3.2/tests/git_test.py +199 -0
- messygit-0.3.2/tests/llm_test.py +105 -0
- messygit-0.3.2/tests/tool_schema_test.py +86 -0
- messygit-0.3.1/messygit/agent/tools.py +0 -82
- {messygit-0.3.1 → messygit-0.3.2}/.github/workflows/publish.yml +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/.gitignore +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/.vscode/settings.json +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/ARCHITECTURE.md +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/README.md +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/__init__.py +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/config.py +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/llm.py +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/models.py +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/prompts.py +0 -0
- {messygit-0.3.1 → messygit-0.3.2}/messygit/usage.py +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
|
|
23
|
+
- name: Install package with dev dependencies
|
|
24
|
+
run: pip install -e ".[dev]"
|
|
25
|
+
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: pytest -q
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: messygit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: An AI-powered interactive git CLI with agentic tools for commits, code suggestions, and workflow automation.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Requires-Dist: anthropic>=0.39.0
|
|
8
8
|
Requires-Dist: click>=8.0
|
|
9
9
|
Requires-Dist: rich>=13.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
12
|
Description-Content-Type: text/markdown
|
|
11
13
|
|
|
12
14
|
# messygit
|
|
@@ -53,8 +53,25 @@ class Agent:
|
|
|
53
53
|
|
|
54
54
|
tool_results = []
|
|
55
55
|
for block in tool_use_blocks:
|
|
56
|
-
tool = next(t for t in self.tools if t.name == block.name)
|
|
57
|
-
|
|
56
|
+
tool = next((t for t in self.tools if t.name == block.name), None)
|
|
57
|
+
if tool is None:
|
|
58
|
+
tool_results.append({
|
|
59
|
+
"type": "tool_result",
|
|
60
|
+
"tool_use_id": block.id,
|
|
61
|
+
"content": f"Unknown tool: {block.name!r}.",
|
|
62
|
+
"is_error": True,
|
|
63
|
+
})
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
result = tool.run(**block.input)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
tool_results.append({
|
|
69
|
+
"type": "tool_result",
|
|
70
|
+
"tool_use_id": block.id,
|
|
71
|
+
"content": f"Error running tool {block.name!r}: {e}",
|
|
72
|
+
"is_error": True,
|
|
73
|
+
})
|
|
74
|
+
continue
|
|
58
75
|
tool_results.append({
|
|
59
76
|
"type": "tool_result",
|
|
60
77
|
"tool_use_id": block.id,
|
|
@@ -12,17 +12,21 @@ class Tool:
|
|
|
12
12
|
description: str
|
|
13
13
|
function: Callable[..., Any]
|
|
14
14
|
parameters: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
required: list[str] = field(default_factory=list)
|
|
15
16
|
|
|
16
17
|
def run(self, **kwargs: Any) -> Any:
|
|
17
18
|
return self.function(**kwargs)
|
|
18
19
|
|
|
19
20
|
def to_schema(self) -> dict[str, Any]:
|
|
20
21
|
"""Return an Anthropic-compatible tool schema for API calls."""
|
|
22
|
+
input_schema: dict[str, Any] = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": self.parameters,
|
|
25
|
+
}
|
|
26
|
+
if self.required:
|
|
27
|
+
input_schema["required"] = self.required
|
|
21
28
|
return {
|
|
22
29
|
"name": self.name,
|
|
23
30
|
"description": self.description,
|
|
24
|
-
"input_schema":
|
|
25
|
-
"type": "object",
|
|
26
|
-
"properties": self.parameters,
|
|
27
|
-
},
|
|
31
|
+
"input_schema": input_schema,
|
|
28
32
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from .tool import Tool
|
|
3
|
+
from ..git import get_staged_diff, get_staged_files
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
ALLOWED_GIT_COMMANDS = ["log", "diff", "status", "show", "status", "shortlog", "blame"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _repo_root() -> str:
|
|
10
|
+
"""Absolute, symlink-resolved path to the git repository root."""
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
13
|
+
capture_output=True,
|
|
14
|
+
text=True,
|
|
15
|
+
)
|
|
16
|
+
root = result.stdout.strip() or os.getcwd()
|
|
17
|
+
return os.path.realpath(root)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_git(args: list[str]) -> str:
|
|
21
|
+
if not args or args[0] not in ALLOWED_GIT_COMMANDS:
|
|
22
|
+
return "Invalid git command."
|
|
23
|
+
result = subprocess.run(["git", *args], capture_output=True, text=True)
|
|
24
|
+
return result.stdout or result.stderr
|
|
25
|
+
|
|
26
|
+
run_git_tool = Tool(
|
|
27
|
+
name="run_git",
|
|
28
|
+
description=(
|
|
29
|
+
"Run a read-only git command and return its output. The first element "
|
|
30
|
+
"of `args` must be one of: "
|
|
31
|
+
+ ", ".join(sorted(set(ALLOWED_GIT_COMMANDS)))
|
|
32
|
+
+ ". Example: [\"log\", \"--oneline\", \"-n\", \"10\"]."
|
|
33
|
+
),
|
|
34
|
+
function=run_git,
|
|
35
|
+
parameters={
|
|
36
|
+
"args": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {"type": "string"},
|
|
39
|
+
"description": (
|
|
40
|
+
"git subcommand and its arguments as separate strings, e.g. "
|
|
41
|
+
"[\"diff\", \"--stat\"]. The subcommand (first item) must be allowed."
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required=["args"],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def read_file(path: str) -> str:
|
|
49
|
+
root = _repo_root()
|
|
50
|
+
target = os.path.realpath(os.path.join(root, path))
|
|
51
|
+
if target != root and not target.startswith(root + os.sep):
|
|
52
|
+
return f"Access denied: '{path}' is outside the repository root."
|
|
53
|
+
try:
|
|
54
|
+
with open(target, "r") as file:
|
|
55
|
+
return file.read()
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return "File not found."
|
|
58
|
+
except IsADirectoryError:
|
|
59
|
+
return "Path is a directory, not a file."
|
|
60
|
+
except PermissionError:
|
|
61
|
+
return "Permission denied."
|
|
62
|
+
except UnicodeDecodeError:
|
|
63
|
+
return "File is not valid UTF-8 text and cannot be read."
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return f"Error reading file: {e}"
|
|
66
|
+
|
|
67
|
+
read_file_tool = Tool(
|
|
68
|
+
name="read_file",
|
|
69
|
+
description=(
|
|
70
|
+
"Read and return the UTF-8 text contents of a file inside the repository. "
|
|
71
|
+
"Paths are resolved relative to the repository root; paths that escape the "
|
|
72
|
+
"repository (e.g. via '..' or absolute paths) are rejected."
|
|
73
|
+
),
|
|
74
|
+
function=read_file,
|
|
75
|
+
parameters={
|
|
76
|
+
"path": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"description": (
|
|
79
|
+
"File path relative to the repository root, e.g. "
|
|
80
|
+
"\"messygit/cli.py\"."
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
required=["path"],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def list_directory(path: str) -> list[str]:
|
|
88
|
+
root = _repo_root()
|
|
89
|
+
target = os.path.realpath(os.path.join(root, path))
|
|
90
|
+
if target != root and not target.startswith(root + os.sep):
|
|
91
|
+
return f"Access denied: '{path}' is outside the repository root."
|
|
92
|
+
try:
|
|
93
|
+
return os.listdir(target)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
return "Directory not found."
|
|
96
|
+
except NotADirectoryError:
|
|
97
|
+
return "Path is a file, not a directory."
|
|
98
|
+
except PermissionError:
|
|
99
|
+
return "Permission denied."
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return f"Error listing directory: {e}"
|
|
102
|
+
|
|
103
|
+
list_directory_tool = Tool(
|
|
104
|
+
name="list_directory",
|
|
105
|
+
description=(
|
|
106
|
+
"List the names of entries (files and subdirectories) in a directory. "
|
|
107
|
+
"Use \".\" for the current directory."
|
|
108
|
+
),
|
|
109
|
+
function=list_directory,
|
|
110
|
+
parameters={
|
|
111
|
+
"path": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"description": "Directory path to list, e.g. \"messygit\" or \".\".",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
required=["path"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def search_code(query: str) -> str:
|
|
120
|
+
result = subprocess.run(["git", "grep", "-n", query], capture_output=True, text=True)
|
|
121
|
+
return result.stdout or result.stderr
|
|
122
|
+
|
|
123
|
+
search_code_tool = Tool(
|
|
124
|
+
name="search_code",
|
|
125
|
+
description=(
|
|
126
|
+
"Search tracked files in the repository for a string or regex using "
|
|
127
|
+
"`git grep`. Returns matching lines prefixed with file path and line number."
|
|
128
|
+
),
|
|
129
|
+
function=search_code,
|
|
130
|
+
parameters={
|
|
131
|
+
"query": {
|
|
132
|
+
"type": "string",
|
|
133
|
+
"description": "The text or basic regex pattern to search for.",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
required=["query"],
|
|
137
|
+
)
|
|
@@ -32,6 +32,7 @@ from .git import (
|
|
|
32
32
|
get_current_branch,
|
|
33
33
|
get_repo_status,
|
|
34
34
|
get_staged_diff,
|
|
35
|
+
get_unpushed_commits,
|
|
35
36
|
git_add,
|
|
36
37
|
git_commit,
|
|
37
38
|
git_push,
|
|
@@ -112,6 +113,7 @@ HELP_GROUPS = [
|
|
|
112
113
|
("add", "stage files", "add . or add <file> ..."),
|
|
113
114
|
("commit", "generate a commit message from staged changes", "commit"),
|
|
114
115
|
("push", "push commits to remote", "push"),
|
|
116
|
+
("outbox", "show committed but unpushed commits", "outbox"),
|
|
115
117
|
]),
|
|
116
118
|
("messyagent", [
|
|
117
119
|
("suggest", "suggest next steps for your project", "suggest"),
|
|
@@ -413,6 +415,33 @@ def _handle_push() -> None:
|
|
|
413
415
|
_success(output if output else "Pushed successfully.")
|
|
414
416
|
|
|
415
417
|
|
|
418
|
+
def _handle_outbox() -> None:
|
|
419
|
+
if not is_git_repo():
|
|
420
|
+
_print_error("Not a git repository.")
|
|
421
|
+
return
|
|
422
|
+
box = get_unpushed_commits()
|
|
423
|
+
if box.upstream is None:
|
|
424
|
+
_print_error(
|
|
425
|
+
"No upstream branch set — push once with 'git push -u' to start tracking."
|
|
426
|
+
)
|
|
427
|
+
return
|
|
428
|
+
if not box.commits:
|
|
429
|
+
_success(f"Up to date with [{BRAND}]{box.upstream}[/] — nothing to push.")
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
n = len(box.commits)
|
|
433
|
+
body = Text()
|
|
434
|
+
body.append(
|
|
435
|
+
f"{n} commit{'s' if n != 1 else ''} ahead of {box.upstream}\n\n", style=MUTED
|
|
436
|
+
)
|
|
437
|
+
for commit in box.commits:
|
|
438
|
+
body.append(f"{commit.short_hash} ", style=BRAND)
|
|
439
|
+
body.append(f"{commit.subject}\n", style="default")
|
|
440
|
+
console.print(
|
|
441
|
+
Panel(body, title="outbox", border_style=BRAND, title_align="left")
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
416
445
|
def _handle_commit() -> None:
|
|
417
446
|
diff = get_staged_diff()
|
|
418
447
|
if not diff.strip():
|
|
@@ -657,6 +686,7 @@ COMMANDS = {
|
|
|
657
686
|
"add": _handle_add,
|
|
658
687
|
"commit": lambda args: _handle_commit(),
|
|
659
688
|
"push": lambda args: _handle_push(),
|
|
689
|
+
"outbox": lambda args: _handle_outbox(),
|
|
660
690
|
"config": _handle_config,
|
|
661
691
|
"show": lambda args: _handle_show(),
|
|
662
692
|
"suggest": lambda args: _handle_suggestion(),
|
|
@@ -313,4 +313,48 @@ def git_commit(message: str) -> CompletedProcess[str]:
|
|
|
313
313
|
["git", "commit", "-m", message],
|
|
314
314
|
capture_output=True,
|
|
315
315
|
text=True,
|
|
316
|
-
)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass
|
|
320
|
+
class UnpushedCommit:
|
|
321
|
+
short_hash: str
|
|
322
|
+
subject: str
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class Outbox:
|
|
327
|
+
"""Commits made locally but not yet on the upstream branch."""
|
|
328
|
+
|
|
329
|
+
upstream: str | None
|
|
330
|
+
commits: list[UnpushedCommit]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def get_unpushed_commits() -> Outbox:
|
|
334
|
+
"""Return commits that are ahead of the current branch's upstream.
|
|
335
|
+
|
|
336
|
+
`upstream` is None when the branch has no configured upstream (e.g. it was
|
|
337
|
+
never pushed); in that case `commits` is empty and the caller should tell the
|
|
338
|
+
user to push/track the branch first.
|
|
339
|
+
"""
|
|
340
|
+
upstream = subprocess.run(
|
|
341
|
+
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
342
|
+
capture_output=True,
|
|
343
|
+
text=True,
|
|
344
|
+
)
|
|
345
|
+
if upstream.returncode != 0:
|
|
346
|
+
return Outbox(upstream=None, commits=[])
|
|
347
|
+
upstream_ref = upstream.stdout.strip()
|
|
348
|
+
|
|
349
|
+
log = subprocess.run(
|
|
350
|
+
["git", "log", "--format=%h%x00%s", "@{upstream}..HEAD"],
|
|
351
|
+
capture_output=True,
|
|
352
|
+
text=True,
|
|
353
|
+
)
|
|
354
|
+
commits: list[UnpushedCommit] = []
|
|
355
|
+
for line in log.stdout.splitlines():
|
|
356
|
+
if not line:
|
|
357
|
+
continue
|
|
358
|
+
short_hash, _, subject = line.partition("\x00")
|
|
359
|
+
commits.append(UnpushedCommit(short_hash=short_hash, subject=subject))
|
|
360
|
+
return Outbox(upstream=upstream_ref, commits=commits)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "messygit"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "An AI-powered interactive git CLI with agentic tools for commits, code suggestions, and workflow automation."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -17,3 +17,6 @@ dependencies = [
|
|
|
17
17
|
|
|
18
18
|
[project.scripts]
|
|
19
19
|
messygit = "messygit.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=8.0"]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Drive Agent.run against a simulated Anthropic client.
|
|
2
|
+
|
|
3
|
+
We replace the SDK client with a FakeClient that returns a preset queue of
|
|
4
|
+
responses (text and/or tool_use blocks) and records every messages.create()
|
|
5
|
+
call, so we can assert the agent actually executes tools, feeds results back,
|
|
6
|
+
handles errors, and respects its iteration cap — all without a network call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from messygit.agent import agent as agent_mod
|
|
14
|
+
from messygit.agent.agent import Agent
|
|
15
|
+
from messygit.agent.tool import Tool
|
|
16
|
+
from messygit.usage import SESSION_USAGE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- fakes for the Anthropic SDK surface the agent touches ----------------
|
|
20
|
+
|
|
21
|
+
class FakeUsage:
|
|
22
|
+
def __init__(self, input_tokens=10, output_tokens=5):
|
|
23
|
+
self.input_tokens = input_tokens
|
|
24
|
+
self.output_tokens = output_tokens
|
|
25
|
+
self.cache_creation_input_tokens = 0
|
|
26
|
+
self.cache_read_input_tokens = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def text_block(text):
|
|
30
|
+
return SimpleNamespace(type="text", text=text)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tool_use_block(name, tool_input, id="tu_1"):
|
|
34
|
+
return SimpleNamespace(type="tool_use", name=name, input=tool_input, id=id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def response(*blocks, usage=None):
|
|
38
|
+
return SimpleNamespace(content=list(blocks), usage=usage or FakeUsage())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FakeMessages:
|
|
42
|
+
def __init__(self, scripted):
|
|
43
|
+
self._scripted = list(scripted)
|
|
44
|
+
self.calls = [] # every kwargs dict passed to create()
|
|
45
|
+
|
|
46
|
+
def create(self, **kwargs):
|
|
47
|
+
# The agent mutates one `messages` list in place across iterations, so
|
|
48
|
+
# snapshot it here to capture what was actually sent on *this* call.
|
|
49
|
+
snapshot = dict(kwargs)
|
|
50
|
+
snapshot["messages"] = list(kwargs["messages"])
|
|
51
|
+
self.calls.append(snapshot)
|
|
52
|
+
if not self._scripted:
|
|
53
|
+
raise AssertionError("agent made more API calls than were scripted")
|
|
54
|
+
return self._scripted.pop(0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FakeClient:
|
|
58
|
+
def __init__(self, scripted):
|
|
59
|
+
self.messages = FakeMessages(scripted)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- test helpers ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def make_spy_tool(name="read_file", returns="FILE CONTENTS"):
|
|
65
|
+
"""A real Tool wrapping a spy fn that records the kwargs it was called with."""
|
|
66
|
+
calls = []
|
|
67
|
+
|
|
68
|
+
def fn(**kwargs):
|
|
69
|
+
calls.append(kwargs)
|
|
70
|
+
return returns
|
|
71
|
+
|
|
72
|
+
tool = Tool(
|
|
73
|
+
name=name,
|
|
74
|
+
description="spy tool",
|
|
75
|
+
function=fn,
|
|
76
|
+
parameters={"path": {"type": "string"}},
|
|
77
|
+
required=["path"],
|
|
78
|
+
)
|
|
79
|
+
return tool, calls
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def last_tool_results(client):
|
|
83
|
+
"""The tool_result list the agent sent on its most recent create() call."""
|
|
84
|
+
return client.messages.calls[-1]["messages"][-1]["content"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture(autouse=True)
|
|
88
|
+
def reset_usage():
|
|
89
|
+
SESSION_USAGE.input = SESSION_USAGE.output = SESSION_USAGE.requests = 0
|
|
90
|
+
SESSION_USAGE.cost = 0.0
|
|
91
|
+
yield
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.fixture
|
|
95
|
+
def run_agent(monkeypatch):
|
|
96
|
+
"""Patch the agent's SDK dependencies and run it against scripted responses."""
|
|
97
|
+
model = SimpleNamespace(
|
|
98
|
+
id="claude-test", input_cost_per_token=0.0, output_cost_per_token=0.0
|
|
99
|
+
)
|
|
100
|
+
monkeypatch.setattr(agent_mod, "current_model", lambda: model)
|
|
101
|
+
monkeypatch.setattr(agent_mod, "resolve_api_key", lambda: "test-key")
|
|
102
|
+
|
|
103
|
+
def _run(scripted, tools, user_input="go", max_iterations=8):
|
|
104
|
+
client = FakeClient(scripted)
|
|
105
|
+
monkeypatch.setattr(agent_mod, "Anthropic", lambda **kwargs: client)
|
|
106
|
+
agent = Agent(
|
|
107
|
+
name="t",
|
|
108
|
+
system_prompt="sys",
|
|
109
|
+
max_iterations=max_iterations,
|
|
110
|
+
tools=tools,
|
|
111
|
+
)
|
|
112
|
+
result = agent.run(user_input)
|
|
113
|
+
return result, client
|
|
114
|
+
|
|
115
|
+
return _run
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- the happy path: no tools, then with tools ----------------------------
|
|
119
|
+
|
|
120
|
+
def test_returns_text_without_calling_tools(run_agent):
|
|
121
|
+
tool, calls = make_spy_tool()
|
|
122
|
+
result, client = run_agent([response(text_block("just an answer"))], [tool])
|
|
123
|
+
assert result == "just an answer"
|
|
124
|
+
assert calls == [] # tool never invoked
|
|
125
|
+
assert len(client.messages.calls) == 1 # only one round-trip
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_executes_tool_then_returns_final_text(run_agent):
|
|
129
|
+
tool, calls = make_spy_tool(returns="print('hi')")
|
|
130
|
+
scripted = [
|
|
131
|
+
response(tool_use_block("read_file", {"path": "x.py"}, id="tu_1")),
|
|
132
|
+
response(text_block("the file prints hi")),
|
|
133
|
+
]
|
|
134
|
+
result, client = run_agent(scripted, [tool])
|
|
135
|
+
assert calls == [{"path": "x.py"}] # tool ran with the model's args
|
|
136
|
+
assert result == "the file prints hi"
|
|
137
|
+
assert len(client.messages.calls) == 2
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_tool_result_is_sent_back_to_model(run_agent):
|
|
141
|
+
tool, _ = make_spy_tool(returns="RESULT-DATA")
|
|
142
|
+
scripted = [
|
|
143
|
+
response(tool_use_block("read_file", {"path": "x.py"}, id="tu_42")),
|
|
144
|
+
response(text_block("done")),
|
|
145
|
+
]
|
|
146
|
+
_, client = run_agent(scripted, [tool])
|
|
147
|
+
tr = last_tool_results(client)[0]
|
|
148
|
+
assert tr["type"] == "tool_result"
|
|
149
|
+
assert tr["tool_use_id"] == "tu_42" # id is threaded through correctly
|
|
150
|
+
assert tr["content"] == "RESULT-DATA"
|
|
151
|
+
assert "is_error" not in tr
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_first_call_sends_user_input_and_tool_schemas(run_agent):
|
|
155
|
+
tool, _ = make_spy_tool()
|
|
156
|
+
_, client = run_agent([response(text_block("ok"))], [tool], user_input="analyze repo")
|
|
157
|
+
first = client.messages.calls[0]
|
|
158
|
+
assert first["messages"][0] == {"role": "user", "content": "analyze repo"}
|
|
159
|
+
assert first["system"] == "sys"
|
|
160
|
+
assert first["tool_choice"] == {"type": "auto"}
|
|
161
|
+
assert first["tools"][0]["name"] == "read_file"
|
|
162
|
+
assert first["tools"][0]["input_schema"]["type"] == "object"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- multiple tool calls in one assistant turn ----------------------------
|
|
166
|
+
|
|
167
|
+
def test_multiple_tool_uses_in_one_turn_all_execute(run_agent):
|
|
168
|
+
tool, calls = make_spy_tool()
|
|
169
|
+
scripted = [
|
|
170
|
+
response(
|
|
171
|
+
tool_use_block("read_file", {"path": "a"}, id="t1"),
|
|
172
|
+
tool_use_block("read_file", {"path": "b"}, id="t2"),
|
|
173
|
+
),
|
|
174
|
+
response(text_block("both read")),
|
|
175
|
+
]
|
|
176
|
+
result, client = run_agent(scripted, [tool])
|
|
177
|
+
assert calls == [{"path": "a"}, {"path": "b"}]
|
|
178
|
+
assert [tr["tool_use_id"] for tr in last_tool_results(client)] == ["t1", "t2"]
|
|
179
|
+
assert result == "both read"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- error handling -------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def test_unknown_tool_returns_error_result_and_continues(run_agent):
|
|
185
|
+
tool, calls = make_spy_tool(name="read_file")
|
|
186
|
+
scripted = [
|
|
187
|
+
response(tool_use_block("does_not_exist", {}, id="tu_x")),
|
|
188
|
+
response(text_block("recovered")),
|
|
189
|
+
]
|
|
190
|
+
result, client = run_agent(scripted, [tool])
|
|
191
|
+
assert result == "recovered"
|
|
192
|
+
assert calls == [] # the real tool was never called
|
|
193
|
+
tr = client.messages.calls[1]["messages"][-1]["content"][0]
|
|
194
|
+
assert tr["is_error"] is True
|
|
195
|
+
assert "does_not_exist" in tr["content"]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_tool_exception_becomes_error_result(run_agent):
|
|
199
|
+
def boom(**kwargs):
|
|
200
|
+
raise RuntimeError("kaboom")
|
|
201
|
+
|
|
202
|
+
tool = Tool(
|
|
203
|
+
name="read_file",
|
|
204
|
+
description="explodes",
|
|
205
|
+
function=boom,
|
|
206
|
+
parameters={"path": {"type": "string"}},
|
|
207
|
+
required=["path"],
|
|
208
|
+
)
|
|
209
|
+
scripted = [
|
|
210
|
+
response(tool_use_block("read_file", {"path": "x"}, id="tu_e")),
|
|
211
|
+
response(text_block("handled")),
|
|
212
|
+
]
|
|
213
|
+
result, client = run_agent(scripted, [tool])
|
|
214
|
+
assert result == "handled"
|
|
215
|
+
tr = client.messages.calls[1]["messages"][-1]["content"][0]
|
|
216
|
+
assert tr["is_error"] is True
|
|
217
|
+
assert "kaboom" in tr["content"]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --- iteration cap --------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def test_stops_at_max_iterations(run_agent):
|
|
223
|
+
tool, calls = make_spy_tool()
|
|
224
|
+
# The model never yields plain text, so only the cap can end the loop.
|
|
225
|
+
scripted = [
|
|
226
|
+
response(tool_use_block("read_file", {"path": str(i)}, id=f"tu_{i}"))
|
|
227
|
+
for i in range(10)
|
|
228
|
+
]
|
|
229
|
+
result, client = run_agent(scripted, [tool], max_iterations=3)
|
|
230
|
+
assert len(client.messages.calls) == 3 # never exceeds the cap
|
|
231
|
+
assert len(calls) == 3
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# --- usage accounting -----------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def test_usage_is_recorded_per_api_call(run_agent):
|
|
237
|
+
tool, _ = make_spy_tool()
|
|
238
|
+
scripted = [
|
|
239
|
+
response(tool_use_block("read_file", {"path": "a"}, id="t1"), usage=FakeUsage(7, 3)),
|
|
240
|
+
response(text_block("done"), usage=FakeUsage(4, 2)),
|
|
241
|
+
]
|
|
242
|
+
run_agent(scripted, [tool])
|
|
243
|
+
assert SESSION_USAGE.requests == 2
|
|
244
|
+
assert SESSION_USAGE.input == 11 # 7 + 4
|
|
245
|
+
assert SESSION_USAGE.output == 5 # 3 + 2
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from messygit import config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True)
|
|
9
|
+
def isolated_config(tmp_path, monkeypatch):
|
|
10
|
+
"""Redirect config/todo files to a temp dir and clear the API-key env var.
|
|
11
|
+
|
|
12
|
+
autouse=True means every test in this module runs against a throwaway
|
|
13
|
+
directory instead of the real ~/.messygit, and never sees a key that
|
|
14
|
+
happens to be exported in the developer's (or CI runner's) environment.
|
|
15
|
+
"""
|
|
16
|
+
config_dir = tmp_path / ".messygit"
|
|
17
|
+
monkeypatch.setattr(config, "CONFIG_DIR", config_dir)
|
|
18
|
+
monkeypatch.setattr(config, "CONFIG_FILE", config_dir / "config.json")
|
|
19
|
+
monkeypatch.setattr(config, "TODO_FILE", config_dir / "todo.md")
|
|
20
|
+
monkeypatch.delenv(config.ANTHROPIC_ENV_VAR, raising=False)
|
|
21
|
+
return config_dir
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- save_api_key / load_api_key ------------------------------------------
|
|
25
|
+
|
|
26
|
+
def test_save_then_load_api_key_roundtrips():
|
|
27
|
+
config.save_api_key("sk-ant-secret")
|
|
28
|
+
assert config.load_api_key() == "sk-ant-secret"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_save_api_key_strips_surrounding_whitespace():
|
|
32
|
+
config.save_api_key(" sk-ant-secret\n")
|
|
33
|
+
assert config.load_api_key() == "sk-ant-secret"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("bad", ["", " ", "\n\t"])
|
|
37
|
+
def test_save_api_key_rejects_blank(bad):
|
|
38
|
+
with pytest.raises(ValueError):
|
|
39
|
+
config.save_api_key(bad)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_load_api_key_returns_none_when_unset():
|
|
43
|
+
assert config.load_api_key() is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_save_api_key_preserves_other_keys():
|
|
47
|
+
config.save_theme("midnight")
|
|
48
|
+
config.save_api_key("sk-ant-secret")
|
|
49
|
+
assert config.load_theme() == "midnight"
|
|
50
|
+
assert config.load_api_key() == "sk-ant-secret"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- resolve_api_key ------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def test_resolve_prefers_env_over_file(monkeypatch):
|
|
56
|
+
config.save_api_key("file-key")
|
|
57
|
+
monkeypatch.setenv(config.ANTHROPIC_ENV_VAR, "env-key")
|
|
58
|
+
assert config.resolve_api_key() == "env-key"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_resolve_falls_back_to_file_when_env_absent():
|
|
62
|
+
config.save_api_key("file-key")
|
|
63
|
+
assert config.resolve_api_key() == "file-key"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_resolve_raises_when_env_set_but_blank(monkeypatch):
|
|
67
|
+
monkeypatch.setenv(config.ANTHROPIC_ENV_VAR, " ")
|
|
68
|
+
with pytest.raises(config.MissingApiKeyError) as exc:
|
|
69
|
+
config.resolve_api_key()
|
|
70
|
+
assert exc.value.args[0] == config.EMPTY_ENV_API_KEY_MESSAGE
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_resolve_raises_when_nothing_configured():
|
|
74
|
+
with pytest.raises(config.MissingApiKeyError) as exc:
|
|
75
|
+
config.resolve_api_key()
|
|
76
|
+
assert exc.value.args[0] == config.MISSING_API_KEY_MESSAGE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- theme / model --------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def test_theme_roundtrips():
|
|
82
|
+
config.save_theme("midnight")
|
|
83
|
+
assert config.load_theme() == "midnight"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_model_roundtrips():
|
|
87
|
+
config.save_model("claude-haiku-4-5")
|
|
88
|
+
assert config.load_model() == "claude-haiku-4-5"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- todo -----------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def test_todo_roundtrips():
|
|
94
|
+
config.save_todo("- ship outbox command\n")
|
|
95
|
+
assert config.load_todo() == "- ship outbox command\n"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_load_todo_empty_when_missing():
|
|
99
|
+
assert config.load_todo() == ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- corrupt / malformed config ------------------------------------------
|
|
103
|
+
|
|
104
|
+
def test_corrupt_config_is_ignored(isolated_config):
|
|
105
|
+
isolated_config.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
(isolated_config / "config.json").write_text("{not valid json")
|
|
107
|
+
# _read_config swallows the error and returns {}, so loads are None.
|
|
108
|
+
assert config.load_api_key() is None
|
|
109
|
+
# ...and a subsequent save overwrites the garbage cleanly.
|
|
110
|
+
config.save_api_key("sk-ant-secret")
|
|
111
|
+
assert json.loads((isolated_config / "config.json").read_text()) == {
|
|
112
|
+
"api_key": "sk-ant-secret"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- mask_api_key ---------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def test_mask_api_key_hides_middle():
|
|
119
|
+
assert config.mask_api_key("sk-ant-abcdefghijklmnop") == "sk-ant-a...mnop"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.parametrize("value,expected", [
|
|
123
|
+
(None, "(not set)"),
|
|
124
|
+
("", "(not set)"),
|
|
125
|
+
("short", "(set)"),
|
|
126
|
+
])
|
|
127
|
+
def test_mask_api_key_edge_cases(value, expected):
|
|
128
|
+
assert config.mask_api_key(value) == expected
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from messygit.git import (
|
|
6
|
+
FileStat,
|
|
7
|
+
_compact_diff_for_files,
|
|
8
|
+
_is_noise_file,
|
|
9
|
+
_parse_compact_diff,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def diff(text: str) -> str:
|
|
14
|
+
"""Dedent a triple-quoted diff fixture and strip the leading newline."""
|
|
15
|
+
return textwrap.dedent(text).lstrip("\n")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- _is_noise_file -------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize("path", [
|
|
21
|
+
"package-lock.json",
|
|
22
|
+
"frontend/yarn.lock",
|
|
23
|
+
"go.sum",
|
|
24
|
+
"app/bundle.min.js",
|
|
25
|
+
"styles/site.min.css",
|
|
26
|
+
"src/api.pb.go",
|
|
27
|
+
"schema.generated.ts",
|
|
28
|
+
"components/Button.snap",
|
|
29
|
+
"nested/dir/.DS_Store",
|
|
30
|
+
])
|
|
31
|
+
def test_noise_files_are_detected(path):
|
|
32
|
+
assert _is_noise_file(path) is True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.parametrize("path", [
|
|
36
|
+
"messygit/cli.py",
|
|
37
|
+
"README.md",
|
|
38
|
+
"src/index.js",
|
|
39
|
+
"lockfile_reader.py", # 'lock' in name but not an actual lockfile
|
|
40
|
+
"minify.py", # 'min' substring, not *.min.js
|
|
41
|
+
])
|
|
42
|
+
def test_real_source_files_are_not_noise(path):
|
|
43
|
+
assert _is_noise_file(path) is False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- _parse_compact_diff --------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def test_parse_single_file_keeps_only_changed_lines():
|
|
49
|
+
raw = diff("""
|
|
50
|
+
diff --git a/messygit/cli.py b/messygit/cli.py
|
|
51
|
+
index 1111111..2222222 100644
|
|
52
|
+
--- a/messygit/cli.py
|
|
53
|
+
+++ b/messygit/cli.py
|
|
54
|
+
@@ -10,1 +10,1 @@ def main():
|
|
55
|
+
- old_call()
|
|
56
|
+
+ new_call()
|
|
57
|
+
""")
|
|
58
|
+
assert _parse_compact_diff(raw) == diff("""
|
|
59
|
+
=== messygit/cli.py ===
|
|
60
|
+
- old_call()
|
|
61
|
+
+ new_call()
|
|
62
|
+
""").rstrip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_parse_drops_metadata_lines():
|
|
66
|
+
"""index, ---, +++ and @@ hunk headers must never appear in output."""
|
|
67
|
+
raw = diff("""
|
|
68
|
+
diff --git a/a.py b/a.py
|
|
69
|
+
index 1111111..2222222 100644
|
|
70
|
+
--- a/a.py
|
|
71
|
+
+++ b/a.py
|
|
72
|
+
@@ -1,0 +1,1 @@
|
|
73
|
+
+added
|
|
74
|
+
""")
|
|
75
|
+
out = _parse_compact_diff(raw)
|
|
76
|
+
assert "index" not in out
|
|
77
|
+
assert "@@" not in out
|
|
78
|
+
assert "---" not in out
|
|
79
|
+
assert "+++" not in out
|
|
80
|
+
assert out == "=== a.py ===\n+added"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_parse_multiple_files():
|
|
84
|
+
raw = diff("""
|
|
85
|
+
diff --git a/one.py b/one.py
|
|
86
|
+
--- a/one.py
|
|
87
|
+
+++ b/one.py
|
|
88
|
+
@@ -1 +1 @@
|
|
89
|
+
-a
|
|
90
|
+
+b
|
|
91
|
+
diff --git a/two.py b/two.py
|
|
92
|
+
--- a/two.py
|
|
93
|
+
+++ b/two.py
|
|
94
|
+
@@ -1 +0,0 @@
|
|
95
|
+
-gone
|
|
96
|
+
""")
|
|
97
|
+
# A blank line separates files: each header is emitted with a leading "\n",
|
|
98
|
+
# and the very first one is removed by the trailing .strip().
|
|
99
|
+
assert _parse_compact_diff(raw) == diff("""
|
|
100
|
+
=== one.py ===
|
|
101
|
+
-a
|
|
102
|
+
+b
|
|
103
|
+
|
|
104
|
+
=== two.py ===
|
|
105
|
+
-gone
|
|
106
|
+
""").rstrip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_parse_skips_noise_file_entirely():
|
|
110
|
+
"""A noise file should not even get a === header, but real files still do."""
|
|
111
|
+
raw = diff("""
|
|
112
|
+
diff --git a/package-lock.json b/package-lock.json
|
|
113
|
+
--- a/package-lock.json
|
|
114
|
+
+++ b/package-lock.json
|
|
115
|
+
@@ -1 +1 @@
|
|
116
|
+
- "version": "1.0.0"
|
|
117
|
+
+ "version": "1.0.1"
|
|
118
|
+
diff --git a/src/app.py b/src/app.py
|
|
119
|
+
--- a/src/app.py
|
|
120
|
+
+++ b/src/app.py
|
|
121
|
+
@@ -1 +1 @@
|
|
122
|
+
-x = 1
|
|
123
|
+
+x = 2
|
|
124
|
+
""")
|
|
125
|
+
out = _parse_compact_diff(raw)
|
|
126
|
+
assert "package-lock.json" not in out
|
|
127
|
+
assert "version" not in out
|
|
128
|
+
assert out == "=== src/app.py ===\n-x = 1\n+x = 2"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_parse_empty_diff_returns_empty_string():
|
|
132
|
+
assert _parse_compact_diff("") == ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_parse_new_file_only_additions():
|
|
136
|
+
raw = diff("""
|
|
137
|
+
diff --git a/new.py b/new.py
|
|
138
|
+
new file mode 100644
|
|
139
|
+
index 0000000..3333333
|
|
140
|
+
--- /dev/null
|
|
141
|
+
+++ b/new.py
|
|
142
|
+
@@ -0,0 +1,2 @@
|
|
143
|
+
+line one
|
|
144
|
+
+line two
|
|
145
|
+
""")
|
|
146
|
+
assert _parse_compact_diff(raw) == diff("""
|
|
147
|
+
=== new.py ===
|
|
148
|
+
+line one
|
|
149
|
+
+line two
|
|
150
|
+
""").rstrip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- _compact_diff_for_files ---------------------------------------------
|
|
154
|
+
|
|
155
|
+
TWO_FILE_DIFF = diff("""
|
|
156
|
+
diff --git a/keep.py b/keep.py
|
|
157
|
+
--- a/keep.py
|
|
158
|
+
+++ b/keep.py
|
|
159
|
+
@@ -1 +1 @@
|
|
160
|
+
-old
|
|
161
|
+
+new
|
|
162
|
+
diff --git a/drop.py b/drop.py
|
|
163
|
+
--- a/drop.py
|
|
164
|
+
+++ b/drop.py
|
|
165
|
+
@@ -1 +1 @@
|
|
166
|
+
-foo
|
|
167
|
+
+bar
|
|
168
|
+
""")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_compact_for_files_includes_only_selected_paths():
|
|
172
|
+
out = _compact_diff_for_files({"keep.py"}, TWO_FILE_DIFF)
|
|
173
|
+
assert out == "=== keep.py ===\n-old\n+new"
|
|
174
|
+
assert "drop.py" not in out
|
|
175
|
+
assert "bar" not in out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_compact_for_files_selects_multiple():
|
|
179
|
+
out = _compact_diff_for_files({"keep.py", "drop.py"}, TWO_FILE_DIFF)
|
|
180
|
+
assert "=== keep.py ===" in out
|
|
181
|
+
assert "=== drop.py ===" in out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_compact_for_files_empty_selection_returns_empty():
|
|
185
|
+
assert _compact_diff_for_files(set(), TWO_FILE_DIFF) == ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_compact_for_files_unknown_path_returns_empty():
|
|
189
|
+
assert _compact_diff_for_files({"does/not/exist.py"}, TWO_FILE_DIFF) == ""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- FileStat -------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def test_filestat_total_changed_sums_added_and_removed():
|
|
195
|
+
assert FileStat(path="a.py", added=3, removed=2).total_changed == 5
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_filestat_total_changed_zero():
|
|
199
|
+
assert FileStat(path="a.py", added=0, removed=0).total_changed == 0
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from anthropic import APIStatusError, BadRequestError
|
|
3
|
+
|
|
4
|
+
from messygit import llm
|
|
5
|
+
from messygit.config import ANTHROPIC_INSUFFICIENT_BALANCE_MESSAGE
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeBadRequest(BadRequestError):
|
|
9
|
+
"""A BadRequestError we can build without a live httpx response.
|
|
10
|
+
|
|
11
|
+
The real SDK error needs an httpx.Response; the balance helpers only read
|
|
12
|
+
.status_code/.body/.message/.request_id and check isinstance(BadRequestError),
|
|
13
|
+
so we override __init__ and set just those attributes.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *, body=None, message="", status_code=400, request_id=None):
|
|
17
|
+
self.body = body
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.request_id = request_id
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeAPIStatusError(APIStatusError):
|
|
24
|
+
"""A non-BadRequest APIStatusError (e.g. a 402 or 5xx)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, body=None, message="", status_code=500, request_id=None):
|
|
27
|
+
self.body = body
|
|
28
|
+
self.message = message
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def billing_body(message="Your credit balance is too low"):
|
|
34
|
+
return {"type": "error", "error": {"type": "billing_error", "message": message}}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- _is_insufficient_balance_or_billing ----------------------------------
|
|
38
|
+
|
|
39
|
+
def test_status_402_is_billing_even_without_billing_type():
|
|
40
|
+
exc = FakeAPIStatusError(status_code=402, body={"error": {"type": "invalid_request_error"}})
|
|
41
|
+
assert llm._is_insufficient_balance_or_billing(exc) is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_nested_billing_error_type_is_detected():
|
|
45
|
+
exc = FakeAPIStatusError(status_code=400, body=billing_body())
|
|
46
|
+
assert llm._is_insufficient_balance_or_billing(exc) is True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("hint", [
|
|
50
|
+
"Your credit balance is too low to access the API.",
|
|
51
|
+
"balance too low",
|
|
52
|
+
"insufficient credit on this account",
|
|
53
|
+
"the account has no credit",
|
|
54
|
+
"you are out of credit",
|
|
55
|
+
])
|
|
56
|
+
def test_bad_request_balance_hints_in_message(hint):
|
|
57
|
+
exc = FakeBadRequest(status_code=400, message=hint)
|
|
58
|
+
assert llm._is_insufficient_balance_or_billing(exc) is True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_bad_request_hint_in_nested_body_message():
|
|
62
|
+
exc = FakeBadRequest(
|
|
63
|
+
status_code=400,
|
|
64
|
+
message="Bad request",
|
|
65
|
+
body={"error": {"type": "invalid_request_error", "message": "credit balance is too low"}},
|
|
66
|
+
)
|
|
67
|
+
assert llm._is_insufficient_balance_or_billing(exc) is True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_unrelated_bad_request_is_not_billing():
|
|
71
|
+
exc = FakeBadRequest(status_code=400, message="max_tokens: must be greater than 0")
|
|
72
|
+
assert llm._is_insufficient_balance_or_billing(exc) is False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_generic_non_billing_status_error_is_not_billing():
|
|
76
|
+
# A 500 with no billing markers: must not be misclassified as a balance issue.
|
|
77
|
+
exc = FakeAPIStatusError(status_code=500, message="internal server error")
|
|
78
|
+
assert llm._is_insufficient_balance_or_billing(exc) is False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_hint_matching_is_case_insensitive():
|
|
82
|
+
exc = FakeBadRequest(status_code=400, message="CREDIT BALANCE TOO LOW")
|
|
83
|
+
assert llm._is_insufficient_balance_or_billing(exc) is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- _insufficient_balance_user_message -----------------------------------
|
|
87
|
+
|
|
88
|
+
def test_user_message_without_request_id_is_the_base_message():
|
|
89
|
+
exc = FakeBadRequest(status_code=400, message="balance too low", request_id=None)
|
|
90
|
+
assert llm._insufficient_balance_user_message(exc) == ANTHROPIC_INSUFFICIENT_BALANCE_MESSAGE
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_user_message_appends_request_id_when_present():
|
|
94
|
+
exc = FakeBadRequest(status_code=400, message="balance too low", request_id="req_abc123")
|
|
95
|
+
msg = llm._insufficient_balance_user_message(exc)
|
|
96
|
+
assert msg.startswith(ANTHROPIC_INSUFFICIENT_BALANCE_MESSAGE)
|
|
97
|
+
assert msg.endswith("Request ID for support: req_abc123.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- nested-body helpers (defensive parsing) ------------------------------
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("body", [None, "a string", 42, {"error": "not a dict"}, {}])
|
|
103
|
+
def test_nested_helpers_tolerate_malformed_bodies(body):
|
|
104
|
+
assert llm._nested_api_error_type(body) is None
|
|
105
|
+
assert llm._nested_api_error_message(body) == ""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from messygit.agent import tools as tools_mod
|
|
4
|
+
from messygit.agent.tool import Tool
|
|
5
|
+
|
|
6
|
+
# Every Tool exposed to the model. The suggestion agent passes the schemas of
|
|
7
|
+
# these straight to client.messages.create(tools=[...]), so each must match the
|
|
8
|
+
# shape the Anthropic Messages API expects for a tool definition.
|
|
9
|
+
ALL_TOOLS = [
|
|
10
|
+
tools_mod.run_git_tool,
|
|
11
|
+
tools_mod.read_file_tool,
|
|
12
|
+
tools_mod.list_directory_tool,
|
|
13
|
+
tools_mod.search_code_tool,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(params=ALL_TOOLS, ids=lambda t: t.name)
|
|
18
|
+
def schema(request):
|
|
19
|
+
return request.param.to_schema()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- top-level shape ------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def test_schema_has_exactly_the_expected_top_level_keys(schema):
|
|
25
|
+
# Anthropic tool definitions are {name, description, input_schema}.
|
|
26
|
+
# "required" belongs *inside* input_schema, never at the top level.
|
|
27
|
+
assert set(schema) == {"name", "description", "input_schema"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_name_is_nonempty_string(schema):
|
|
31
|
+
assert isinstance(schema["name"], str) and schema["name"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_description_is_nonempty_string(schema):
|
|
35
|
+
# A description is optional to the API but we want every tool to have one.
|
|
36
|
+
assert isinstance(schema["description"], str) and schema["description"].strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- input_schema (JSON Schema object) ------------------------------------
|
|
40
|
+
|
|
41
|
+
def test_input_schema_is_a_json_schema_object(schema):
|
|
42
|
+
input_schema = schema["input_schema"]
|
|
43
|
+
assert input_schema["type"] == "object"
|
|
44
|
+
assert isinstance(input_schema["properties"], dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_every_property_declares_a_type(schema):
|
|
48
|
+
for name, prop in schema["input_schema"]["properties"].items():
|
|
49
|
+
assert isinstance(prop, dict), name
|
|
50
|
+
assert "type" in prop, name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_required_is_a_list_referencing_real_properties(schema):
|
|
54
|
+
input_schema = schema["input_schema"]
|
|
55
|
+
if "required" not in input_schema:
|
|
56
|
+
return
|
|
57
|
+
required = input_schema["required"]
|
|
58
|
+
assert isinstance(required, list)
|
|
59
|
+
properties = input_schema["properties"]
|
|
60
|
+
for field in required:
|
|
61
|
+
assert field in properties, f"required field {field!r} missing from properties"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_tool_names_are_unique():
|
|
65
|
+
names = [t.name for t in ALL_TOOLS]
|
|
66
|
+
assert len(names) == len(set(names))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- to_schema behaviour for the `required` field -------------------------
|
|
70
|
+
|
|
71
|
+
def test_required_omitted_when_not_set():
|
|
72
|
+
bare = Tool(name="noop", description="does nothing", function=lambda: None)
|
|
73
|
+
assert "required" not in bare.to_schema()["input_schema"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_required_included_when_set():
|
|
77
|
+
t = Tool(
|
|
78
|
+
name="echo",
|
|
79
|
+
description="echo text",
|
|
80
|
+
function=lambda text: text,
|
|
81
|
+
parameters={"text": {"type": "string"}},
|
|
82
|
+
required=["text"],
|
|
83
|
+
)
|
|
84
|
+
schema = t.to_schema()
|
|
85
|
+
assert schema["input_schema"]["required"] == ["text"]
|
|
86
|
+
assert schema["input_schema"]["properties"]["text"]["type"] == "string"
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from .tool import Tool
|
|
3
|
-
from ..git import get_staged_diff, get_staged_files
|
|
4
|
-
import subprocess
|
|
5
|
-
|
|
6
|
-
ALLOWED_GIT_COMMANDS = ["log", "diff", "status", "show", "status", "shortlog", "blame"]
|
|
7
|
-
|
|
8
|
-
def run_git(args: list[str]) -> str:
|
|
9
|
-
if not args or args[0] not in ALLOWED_GIT_COMMANDS:
|
|
10
|
-
return "Invalid git command."
|
|
11
|
-
result = subprocess.run(["git", *args], capture_output=True, text=True)
|
|
12
|
-
return result.stdout or result.stderr
|
|
13
|
-
|
|
14
|
-
run_git_tool = Tool(
|
|
15
|
-
name="run_git",
|
|
16
|
-
description="Run a git command",
|
|
17
|
-
function=run_git,
|
|
18
|
-
parameters={
|
|
19
|
-
"args": {
|
|
20
|
-
"type": "array",
|
|
21
|
-
"items": {"type": "string"},
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
def read_file(path: str) -> str:
|
|
27
|
-
try:
|
|
28
|
-
with open(path, "r") as file:
|
|
29
|
-
return file.read()
|
|
30
|
-
except FileNotFoundError:
|
|
31
|
-
return "File not found."
|
|
32
|
-
except PermissionError:
|
|
33
|
-
return "Permission denied."
|
|
34
|
-
except Exception as e:
|
|
35
|
-
return f"Error reading file: {e}"
|
|
36
|
-
|
|
37
|
-
read_file_tool = Tool(
|
|
38
|
-
name="read_file",
|
|
39
|
-
description="Read a file",
|
|
40
|
-
function=read_file,
|
|
41
|
-
parameters={
|
|
42
|
-
"path": {
|
|
43
|
-
"type": "string",
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
def list_directory(path: str) -> list[str]:
|
|
49
|
-
try:
|
|
50
|
-
return os.listdir(path)
|
|
51
|
-
except FileNotFoundError:
|
|
52
|
-
return "Directory not found."
|
|
53
|
-
except PermissionError:
|
|
54
|
-
return "Permission denied."
|
|
55
|
-
except Exception as e:
|
|
56
|
-
return f"Error listing directory: {e}"
|
|
57
|
-
|
|
58
|
-
list_directory_tool = Tool(
|
|
59
|
-
name="list_directory",
|
|
60
|
-
description="List a directory",
|
|
61
|
-
function=list_directory,
|
|
62
|
-
parameters={
|
|
63
|
-
"path": {
|
|
64
|
-
"type": "string",
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
def search_code(query: str) -> str:
|
|
70
|
-
result = subprocess.run(["git", "grep", "-n", query], capture_output=True, text=True)
|
|
71
|
-
return result.stdout or result.stderr
|
|
72
|
-
|
|
73
|
-
search_code_tool = Tool(
|
|
74
|
-
name="search_code",
|
|
75
|
-
description="Search the codebase for a query",
|
|
76
|
-
function=search_code,
|
|
77
|
-
parameters={
|
|
78
|
-
"query": {
|
|
79
|
-
"type": "string",
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
)
|
|
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
|