ltcai 0.1.22 → 0.1.24
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.
- package/README.md +227 -256
- package/docs/CHANGELOG.md +79 -1
- package/docs/images/logo.svg +33 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/package.json +1 -2
- package/server.py +17 -4
- package/tests/__init__.py +0 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/test_api.py +0 -94
- package/tests/unit/__init__.py +0 -0
- package/tests/unit/test_security.py +0 -193
- package/tests/unit/test_setup_wizard.py +0 -35
- package/tests/unit/test_tools.py +0 -320
package/tests/unit/test_tools.py
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
"""Unit tests for tools.py core functions."""
|
|
2
|
-
import sys
|
|
3
|
-
import pytest
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
7
|
-
|
|
8
|
-
import tools as tools_module
|
|
9
|
-
from tools import (
|
|
10
|
-
ToolError,
|
|
11
|
-
edit_file,
|
|
12
|
-
grep,
|
|
13
|
-
local_list,
|
|
14
|
-
local_read,
|
|
15
|
-
local_write,
|
|
16
|
-
read_document,
|
|
17
|
-
read_file,
|
|
18
|
-
todo_read,
|
|
19
|
-
todo_write,
|
|
20
|
-
write_file,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@pytest.fixture
|
|
25
|
-
def workspace(tmp_path, monkeypatch):
|
|
26
|
-
"""Redirect tools' AGENT_ROOT to a temp directory for the duration of a test."""
|
|
27
|
-
monkeypatch.setattr(tools_module, "AGENT_ROOT", tmp_path)
|
|
28
|
-
tools_module.ensure_agent_root()
|
|
29
|
-
return tmp_path
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# ---------------------------------------------------------------------------
|
|
33
|
-
# local_list
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
def test_local_list_returns_items(tmp_path):
|
|
37
|
-
(tmp_path / "a.txt").write_text("hello")
|
|
38
|
-
(tmp_path / "sub").mkdir()
|
|
39
|
-
result = local_list(str(tmp_path))
|
|
40
|
-
names = [i["name"] for i in result["items"]]
|
|
41
|
-
assert "a.txt" in names
|
|
42
|
-
assert "sub" in names
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_local_list_dirs_before_files(tmp_path):
|
|
46
|
-
(tmp_path / "z_file.txt").write_text("x")
|
|
47
|
-
(tmp_path / "a_dir").mkdir()
|
|
48
|
-
result = local_list(str(tmp_path))
|
|
49
|
-
types = [i["type"] for i in result["items"]]
|
|
50
|
-
assert types[0] == "directory"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_local_list_nonexistent_raises(tmp_path):
|
|
54
|
-
with pytest.raises(ToolError):
|
|
55
|
-
local_list(str(tmp_path / "does_not_exist"))
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_local_list_file_raises(tmp_path):
|
|
59
|
-
f = tmp_path / "file.txt"
|
|
60
|
-
f.write_text("x")
|
|
61
|
-
with pytest.raises(ToolError):
|
|
62
|
-
local_list(str(f))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# ---------------------------------------------------------------------------
|
|
66
|
-
# local_read
|
|
67
|
-
# ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
def test_local_read_text_file(tmp_path):
|
|
70
|
-
f = tmp_path / "hello.txt"
|
|
71
|
-
f.write_text("hello world")
|
|
72
|
-
result = local_read(str(f))
|
|
73
|
-
assert "hello world" in result["content"]
|
|
74
|
-
assert result["path"] == str(f)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def test_local_read_missing_file_raises(tmp_path):
|
|
78
|
-
with pytest.raises(ToolError):
|
|
79
|
-
local_read(str(tmp_path / "missing.txt"))
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_local_read_tilde_expansion(tmp_path, monkeypatch):
|
|
83
|
-
monkeypatch.setenv("HOME", str(tmp_path))
|
|
84
|
-
f = tmp_path / "testfile.txt"
|
|
85
|
-
f.write_text("tilde test")
|
|
86
|
-
result = local_read("~/testfile.txt")
|
|
87
|
-
assert "tilde test" in result["content"]
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_local_read_returns_size(tmp_path):
|
|
91
|
-
f = tmp_path / "sized.txt"
|
|
92
|
-
f.write_text("abc")
|
|
93
|
-
result = local_read(str(f))
|
|
94
|
-
assert result["size"] == 3
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# ---------------------------------------------------------------------------
|
|
98
|
-
# local_write
|
|
99
|
-
# ---------------------------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
def test_local_write_creates_file(tmp_path):
|
|
102
|
-
target = tmp_path / "out.txt"
|
|
103
|
-
result = local_write(str(target), "new content")
|
|
104
|
-
assert target.read_text() == "new content"
|
|
105
|
-
assert "bytes" in result
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def test_local_write_overwrites_file(tmp_path):
|
|
109
|
-
target = tmp_path / "out.txt"
|
|
110
|
-
target.write_text("old")
|
|
111
|
-
local_write(str(target), "new")
|
|
112
|
-
assert target.read_text() == "new"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def test_local_write_creates_parent_dirs(tmp_path):
|
|
116
|
-
target = tmp_path / "deep" / "nested" / "file.txt"
|
|
117
|
-
local_write(str(target), "deep write")
|
|
118
|
-
assert target.exists()
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def test_local_write_returns_path(tmp_path):
|
|
122
|
-
target = tmp_path / "x.txt"
|
|
123
|
-
result = local_write(str(target), "hi")
|
|
124
|
-
assert result["path"] == str(target)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# ---------------------------------------------------------------------------
|
|
128
|
-
# read_document
|
|
129
|
-
# ---------------------------------------------------------------------------
|
|
130
|
-
|
|
131
|
-
def test_read_document_plain_text(tmp_path):
|
|
132
|
-
f = tmp_path / "doc.txt"
|
|
133
|
-
f.write_text("plain text content")
|
|
134
|
-
result = read_document(str(f))
|
|
135
|
-
assert "plain text content" in result.get("text", "") or "plain text content" in str(result)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def test_read_document_missing_file_raises(tmp_path):
|
|
139
|
-
with pytest.raises(ToolError):
|
|
140
|
-
read_document(str(tmp_path / "missing.pdf"))
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def test_read_document_csv(tmp_path):
|
|
144
|
-
f = tmp_path / "data.csv"
|
|
145
|
-
f.write_text("col1,col2\n1,2\n3,4\n")
|
|
146
|
-
result = read_document(str(f))
|
|
147
|
-
# should not raise; returns some text content
|
|
148
|
-
assert result is not None
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# ---------------------------------------------------------------------------
|
|
152
|
-
# read_file (workspace, with line numbers and offset/limit)
|
|
153
|
-
# ---------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
def test_read_file_returns_numbered_view(workspace):
|
|
156
|
-
(workspace / "a.txt").write_text("alpha\nbeta\ngamma\n")
|
|
157
|
-
result = read_file("a.txt")
|
|
158
|
-
assert result["total_lines"] == 3
|
|
159
|
-
assert result["start_line"] == 1
|
|
160
|
-
assert result["end_line"] == 3
|
|
161
|
-
assert "1\talpha" in result["numbered"]
|
|
162
|
-
assert "3\tgamma" in result["numbered"]
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def test_read_file_offset_and_limit(workspace):
|
|
166
|
-
(workspace / "a.txt").write_text("\n".join(f"line{i}" for i in range(1, 11)))
|
|
167
|
-
result = read_file("a.txt", offset=3, limit=2)
|
|
168
|
-
assert result["start_line"] == 4
|
|
169
|
-
assert result["end_line"] == 5
|
|
170
|
-
assert result["content"].splitlines() == ["line4", "line5"]
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def test_read_file_disable_line_numbers(workspace):
|
|
174
|
-
(workspace / "a.txt").write_text("only\n")
|
|
175
|
-
result = read_file("a.txt", line_numbers=False)
|
|
176
|
-
assert "numbered" not in result
|
|
177
|
-
assert result["content"] == "only\n"
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
# ---------------------------------------------------------------------------
|
|
181
|
-
# edit_file
|
|
182
|
-
# ---------------------------------------------------------------------------
|
|
183
|
-
|
|
184
|
-
def test_edit_file_replaces_unique_match(workspace):
|
|
185
|
-
(workspace / "code.py").write_text("def foo():\n return 1\n")
|
|
186
|
-
result = edit_file("code.py", "return 1", "return 42")
|
|
187
|
-
assert (workspace / "code.py").read_text() == "def foo():\n return 42\n"
|
|
188
|
-
assert result["replacements"] == 1
|
|
189
|
-
assert result["first_edit_line"] == 2
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def test_edit_file_missing_string_raises(workspace):
|
|
193
|
-
(workspace / "code.py").write_text("hello\n")
|
|
194
|
-
with pytest.raises(ToolError, match="not found"):
|
|
195
|
-
edit_file("code.py", "missing", "world")
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def test_edit_file_ambiguous_raises_unless_replace_all(workspace):
|
|
199
|
-
(workspace / "code.py").write_text("x = 1\nx = 1\n")
|
|
200
|
-
with pytest.raises(ToolError, match="ambiguous"):
|
|
201
|
-
edit_file("code.py", "x = 1", "x = 2")
|
|
202
|
-
result = edit_file("code.py", "x = 1", "x = 2", replace_all=True)
|
|
203
|
-
assert result["replacements"] == 2
|
|
204
|
-
assert (workspace / "code.py").read_text() == "x = 2\nx = 2\n"
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def test_edit_file_rejects_identical(workspace):
|
|
208
|
-
(workspace / "code.py").write_text("same\n")
|
|
209
|
-
with pytest.raises(ToolError, match="identical"):
|
|
210
|
-
edit_file("code.py", "same", "same")
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
# ---------------------------------------------------------------------------
|
|
214
|
-
# grep
|
|
215
|
-
# ---------------------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
def test_grep_finds_regex_matches(workspace):
|
|
218
|
-
(workspace / "a.py").write_text("def foo():\n return 1\n\ndef bar():\n return 2\n")
|
|
219
|
-
(workspace / "b.py").write_text("x = 1\n")
|
|
220
|
-
result = grep(r"^def \w+", path=".")
|
|
221
|
-
paths = sorted({m["path"] for m in result["matches"]})
|
|
222
|
-
assert "a.py" in paths
|
|
223
|
-
assert result["files_with_matches"] == 1
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def test_grep_respects_glob(workspace):
|
|
227
|
-
(workspace / "a.py").write_text("needle\n")
|
|
228
|
-
(workspace / "a.txt").write_text("needle\n")
|
|
229
|
-
result = grep("needle", path=".", glob="*.py")
|
|
230
|
-
paths = [m["path"] for m in result["matches"]]
|
|
231
|
-
assert "a.py" in paths
|
|
232
|
-
assert "a.txt" not in paths
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def test_grep_case_insensitive(workspace):
|
|
236
|
-
(workspace / "a.txt").write_text("HELLO world\n")
|
|
237
|
-
result = grep("hello", path=".", case_insensitive=True)
|
|
238
|
-
assert any("HELLO" in m["match"] for m in result["matches"])
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def test_grep_context_lines(workspace):
|
|
242
|
-
(workspace / "a.txt").write_text("before\nhit\nafter\n")
|
|
243
|
-
result = grep("hit", path=".", context_lines=1)
|
|
244
|
-
assert result["matches"]
|
|
245
|
-
ctx_lines = [c["text"] for c in result["matches"][0]["context"]]
|
|
246
|
-
assert "before" in ctx_lines and "after" in ctx_lines
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def test_grep_invalid_regex_raises(workspace):
|
|
250
|
-
(workspace / "a.txt").write_text("hello\n")
|
|
251
|
-
with pytest.raises(ToolError, match="regex"):
|
|
252
|
-
grep("[unterminated", path=".")
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def test_grep_skips_binary_dirs(workspace):
|
|
256
|
-
(workspace / "node_modules").mkdir()
|
|
257
|
-
(workspace / "node_modules" / "x.js").write_text("needle\n")
|
|
258
|
-
(workspace / "src.py").write_text("needle\n")
|
|
259
|
-
result = grep("needle", path=".")
|
|
260
|
-
paths = [m["path"] for m in result["matches"]]
|
|
261
|
-
assert "src.py" in paths
|
|
262
|
-
assert all("node_modules" not in p for p in paths)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
# ---------------------------------------------------------------------------
|
|
266
|
-
# todo_read / todo_write
|
|
267
|
-
# ---------------------------------------------------------------------------
|
|
268
|
-
|
|
269
|
-
def test_todo_read_empty_when_unset(workspace):
|
|
270
|
-
result = todo_read()
|
|
271
|
-
assert result["todos"] == []
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def test_todo_write_round_trip(workspace):
|
|
275
|
-
todos = [
|
|
276
|
-
{"id": "1", "content": "design API", "status": "completed"},
|
|
277
|
-
{"id": "2", "content": "write tests", "status": "in_progress"},
|
|
278
|
-
{"id": "3", "content": "deploy", "status": "pending"},
|
|
279
|
-
]
|
|
280
|
-
todo_write(todos)
|
|
281
|
-
fresh = todo_read()
|
|
282
|
-
assert [t["content"] for t in fresh["todos"]] == ["design API", "write tests", "deploy"]
|
|
283
|
-
assert fresh["todos"][1]["status"] == "in_progress"
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def test_todo_write_rejects_invalid_status(workspace):
|
|
287
|
-
with pytest.raises(ToolError, match="status"):
|
|
288
|
-
todo_write([{"id": "1", "content": "x", "status": "blocked"}])
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def test_todo_write_rejects_missing_content(workspace):
|
|
292
|
-
with pytest.raises(ToolError, match="content"):
|
|
293
|
-
todo_write([{"id": "1", "content": "", "status": "pending"}])
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def test_todo_write_warns_multiple_in_progress(workspace):
|
|
297
|
-
result = todo_write([
|
|
298
|
-
{"id": "1", "content": "a", "status": "in_progress"},
|
|
299
|
-
{"id": "2", "content": "b", "status": "in_progress"},
|
|
300
|
-
])
|
|
301
|
-
assert result["warning"]
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
# ---------------------------------------------------------------------------
|
|
305
|
-
# Sandbox: workspace tools must not escape AGENT_ROOT
|
|
306
|
-
# ---------------------------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
def test_read_file_blocks_path_escape(workspace):
|
|
309
|
-
with pytest.raises(ToolError, match="escapes"):
|
|
310
|
-
read_file("../../etc/passwd")
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def test_edit_file_blocks_path_escape(workspace):
|
|
314
|
-
with pytest.raises(ToolError, match="escapes"):
|
|
315
|
-
edit_file("../../etc/passwd", "a", "b")
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def test_grep_blocks_path_escape(workspace):
|
|
319
|
-
with pytest.raises(ToolError, match="escapes"):
|
|
320
|
-
grep("x", path="../../etc")
|