SHA256
FIX SOME BUGS
This commit is contained in:
+277
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the sjcode client agent layer.
|
||||
|
||||
Run: python3 -m unittest test_client.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import client as c # noqa: E402
|
||||
|
||||
|
||||
class ClientTestCase(unittest.TestCase):
|
||||
"""Each test runs in its own temp dir which becomes the project dir."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._old_cwd = c.CWD
|
||||
self._old_chdir = os.getcwd()
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
c.CWD = Path(self._tmp.name).resolve()
|
||||
os.chdir(c.CWD) # tools use CWD; keep relative paths consistent
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.chdir(self._old_chdir)
|
||||
c.CWD = self._old_cwd
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
class TestParsing(ClientTestCase):
|
||||
def test_extracts_run_and_json_tools_in_order(self) -> None:
|
||||
raw = (
|
||||
"Let me check the repo.\n"
|
||||
"```run\n"
|
||||
"git status --short\n"
|
||||
"```\n"
|
||||
"And now the file:\n"
|
||||
"```sj:read\n"
|
||||
'{"path": "a.py", "limit": 10}\n'
|
||||
"```\n"
|
||||
)
|
||||
tags = c.read_tool_tags(raw)
|
||||
self.assertEqual([("run", "git status --short"),
|
||||
("read", '{"path": "a.py", "limit": 10}')], tags)
|
||||
|
||||
def test_language_fence_is_run_tool(self) -> None:
|
||||
self.assertEqual(c.read_tool_tags("```run\necho hi\n```"),
|
||||
[("run", "echo hi")])
|
||||
|
||||
def test_language_fence_is_not_a_run_tool(self) -> None:
|
||||
self.assertEqual(c.read_tool_tags("```rust\nfn main() {}\n```"), [])
|
||||
|
||||
def test_text_before_and_after_is_stripped_from_reply(self) -> None:
|
||||
raw = "Thinking...\n```run\necho hi\n```\nDone."
|
||||
had, reply, results = c.process_reply(raw, auto=True)
|
||||
self.assertTrue(had)
|
||||
self.assertNotIn("```", reply)
|
||||
self.assertNotIn("echo hi", reply)
|
||||
|
||||
def test_body_with_triple_backticks_inside_json_survives(self) -> None:
|
||||
body = '{"path": "x.md", "content": "code: ```py\\nprint(1)\\n```\\n"}'
|
||||
raw = f"```sj:write\n{body}\n```\n"
|
||||
self.assertEqual(c.read_tool_tags(raw), [("write", body)])
|
||||
|
||||
def test_unclosed_fence_still_captured(self) -> None:
|
||||
self.assertEqual(c.read_tool_tags("```run\necho hi"),
|
||||
[("run", "echo hi")])
|
||||
|
||||
|
||||
class TestRunTool(ClientTestCase):
|
||||
def test_runs_command_and_returns_output(self) -> None:
|
||||
ok, lines = c.handle_tool_call("run", '{"command": "echo hello"}', auto=True)
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("hello", "\n".join(lines))
|
||||
|
||||
def test_nonzero_exit_reported(self) -> None:
|
||||
ok, lines = c.handle_tool_call(
|
||||
"run", '{"command": "python3 -c \x27import sys; sys.exit(3)\x27"}',
|
||||
auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("exit 3", "\n".join(lines))
|
||||
|
||||
def test_cwd_argument_scopes_the_command(self) -> None:
|
||||
os.mkdir("sub")
|
||||
Path("sub/marker.txt").write_text("x")
|
||||
ok, lines = c.handle_tool_call(
|
||||
"run", '{"command": "pwd", "cwd": "sub"}', auto=True)
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("sub", "\n".join(lines))
|
||||
|
||||
def test_run_outside_project_dir_is_refused(self) -> None:
|
||||
ok, lines = c.handle_tool_call(
|
||||
"run", json.dumps({"command": "pwd", "cwd": "/tmp"}), auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("outside the project directory", "\n".join(lines))
|
||||
|
||||
def test_missing_command_is_refused(self) -> None:
|
||||
ok, lines = c.handle_tool_call("run", "{}", auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("command", "\n".join(lines))
|
||||
|
||||
|
||||
class TestFileTools(ClientTestCase):
|
||||
def test_read_shows_numbered_lines(self) -> None:
|
||||
Path("a.txt").write_text("one\ntwo\nthree\n")
|
||||
ok, lines = c.handle_tool_call("read", '{"path": "a.txt"}', auto=True)
|
||||
self.assertTrue(ok)
|
||||
joined = "\n".join(lines)
|
||||
self.assertIn("1\tone", joined)
|
||||
self.assertIn("3\tthree", joined)
|
||||
|
||||
def test_read_supports_start_and_limit(self) -> None:
|
||||
Path("a.txt").write_text("\n".join(str(i) for i in range(1, 51)))
|
||||
ok, lines = c.handle_tool_call(
|
||||
"read", '{"path": "a.txt", "start": 40, "limit": 5}', auto=True)
|
||||
self.assertIn("40\t40", "\n".join(lines))
|
||||
|
||||
def test_read_missing_file_gives_error_back(self) -> None:
|
||||
ok, lines = c.handle_tool_call("read", '{"path": "nope.txt"}', auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("not a file", "\n".join(lines))
|
||||
|
||||
def test_files_lists_and_skips_junk_dirs(self) -> None:
|
||||
os.mkdir("pkg")
|
||||
Path("pkg/mod.py").write_text("x = 1\n")
|
||||
os.mkdir(".git")
|
||||
Path(".git/config").write_text("x\n")
|
||||
ok, lines = c.handle_tool_call("files", '{"path": "."}', auto=True)
|
||||
joined = "\n".join(lines)
|
||||
self.assertIn("pkg/mod.py", joined)
|
||||
self.assertNotIn(".git/config", joined)
|
||||
|
||||
def test_write_creates_file_after_confirmation(self) -> None:
|
||||
with mock.patch("client.confirm_tool", return_value="y"):
|
||||
ok, lines = c.handle_tool_call(
|
||||
"write", '{"path": "new.txt", "content": "hi\\n"}', auto=False)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(Path("new.txt").read_text(), "hi\n")
|
||||
|
||||
def test_write_refused_outside_project(self) -> None:
|
||||
ok, lines = c.handle_tool_call(
|
||||
"write",
|
||||
json.dumps({"path": "../../evil.txt", "content": "x"}),
|
||||
auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("outside the project directory", "\n".join(lines))
|
||||
|
||||
def test_edit_replaces_exact_text(self) -> None:
|
||||
Path("app.py").write_text("def main():\n print('old')\n")
|
||||
with mock.patch("client.confirm_tool", return_value="y"):
|
||||
ok, lines = c.handle_tool_call(
|
||||
"edit",
|
||||
json.dumps({"path": "app.py", "old": "print('old')",
|
||||
"new": "print('new')"}),
|
||||
auto=False)
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("print('new')", Path("app.py").read_text())
|
||||
|
||||
def test_edit_requires_unique_match_without_all(self) -> None:
|
||||
Path("app.py").write_text("x = 1\nx = 1\n")
|
||||
ok, lines = c.handle_tool_call(
|
||||
"edit", json.dumps({"path": "app.py", "old": "x = 1", "new": "y"}),
|
||||
auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("matches 2 times", "\n".join(lines))
|
||||
|
||||
def test_edit_with_all_flag_replaces_every_match(self) -> None:
|
||||
Path("app.py").write_text("x = 1\nx = 1\n")
|
||||
with mock.patch("client.confirm_tool", return_value="y"):
|
||||
ok, lines = c.handle_tool_call(
|
||||
"edit",
|
||||
json.dumps({"path": "app.py", "old": "x = 1", "new": "y",
|
||||
"all": True}),
|
||||
auto=False)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(Path("app.py").read_text(), "y\ny\n")
|
||||
|
||||
def test_edit_not_found_gives_actionable_error(self) -> None:
|
||||
Path("app.py").write_text("hello\n")
|
||||
ok, lines = c.handle_tool_call(
|
||||
"edit", json.dumps({"path": "app.py", "old": "zzz", "new": "y"}),
|
||||
auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("search text not found", "\n".join(lines))
|
||||
|
||||
def test_overwrite_of_big_existing_file_refused(self) -> None:
|
||||
Path("big.txt").write_text("x" * 3000)
|
||||
ok, lines = c.handle_tool_call(
|
||||
"write", json.dumps({"path": "big.txt", "content": "y"}), auto=True)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("use 'edit' instead", "\n".join(lines))
|
||||
|
||||
|
||||
class TestConfirmation(ClientTestCase):
|
||||
def test_declined_call_returns_cancelled_to_model(self) -> None:
|
||||
Path("f.txt").write_text("a\n")
|
||||
with mock.patch("client.confirm_tool", return_value="n"):
|
||||
ok, lines = c.handle_tool_call(
|
||||
"edit", json.dumps({"path": "f.txt", "old": "a", "new": "b"}),
|
||||
auto=False)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("cancelled by user", "\n".join(lines))
|
||||
|
||||
def test_user_instruction_is_passed_to_model(self) -> None:
|
||||
Path("f.txt").write_text("a\n")
|
||||
with mock.patch("client.confirm_tool",
|
||||
return_value="use python instead"):
|
||||
ok, lines = c.handle_tool_call("run", '{"command": "ls -la"}',
|
||||
auto=False)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("user says: use python instead", "\n".join(lines))
|
||||
|
||||
def test_abort_raises_toolabort(self) -> None:
|
||||
with mock.patch("client.confirm_tool", return_value=c.ABORT):
|
||||
with self.assertRaises(c.ToolAbort):
|
||||
c.handle_tool_call("run", '{"command": "ls"}', auto=False)
|
||||
|
||||
def test_auto_mode_skips_confirmation(self) -> None:
|
||||
with mock.patch("client.confirm_tool",
|
||||
side_effect=AssertionError("must not be called")):
|
||||
ok, lines = c.handle_tool_call("run", '{"command": "echo ok"}',
|
||||
auto=True)
|
||||
self.assertTrue(ok)
|
||||
|
||||
|
||||
class TestAgentTurn(ClientTestCase):
|
||||
def _chat(self, replies):
|
||||
"""Patch api_chat to serve canned replies in order."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake(entry, token, message, model, on_chunk=None):
|
||||
i = calls["n"]
|
||||
calls["n"] += 1
|
||||
return 200, {"response": replies[min(i, len(replies) - 1)],
|
||||
"remaining_minutes": 99}
|
||||
|
||||
return mock.patch("client.api_chat", side_effect=fake)
|
||||
|
||||
def test_tool_loop_feeds_results_and_finishes_on_done(self) -> None:
|
||||
Path("greet.txt").write_text("hi\n")
|
||||
replies = [
|
||||
'Reading the file.\n```sj:read\n{"path": "greet.txt"}\n```',
|
||||
'```sj:write\n{"path": "out.txt", "content": "done"}\n```',
|
||||
"Everything is in place.\nDone.",
|
||||
]
|
||||
with self._chat(replies):
|
||||
status, final, _ = c.agent_turn({}, "t", "m", [], "make out.txt",
|
||||
auto=True)
|
||||
self.assertEqual(status, "done")
|
||||
self.assertEqual(Path("out.txt").read_text(), "done\n")
|
||||
self.assertEqual(final, "Everything is in place.\nDone.")
|
||||
|
||||
def test_plain_text_reply_ends_turn_without_tool_loop(self) -> None:
|
||||
with self._chat(["Just answering."]):
|
||||
status, final, _ = c.agent_turn({}, "t", "m", [], "hello", auto=True)
|
||||
self.assertEqual(status, "done")
|
||||
self.assertEqual(final, "Just answering.")
|
||||
|
||||
def test_error_result_is_sent_back_to_model(self) -> None:
|
||||
replies = [
|
||||
'```sj:read\n{"path": "missing.txt"}\n```',
|
||||
"Task complete.\nDone.",
|
||||
]
|
||||
with self._chat(replies):
|
||||
status, _, _ = c.agent_turn({}, "t", "m", [], "read it", auto=True)
|
||||
self.assertEqual(status, "done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user