okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petrpc: pass host calls to routes as a method and arguments, and reply once59e1387

main
21.1 KiB414 linesraw
1"""File editing, search, and cell timing are exercised through the daemon's real Python tool."""
2
3import json
4import unittest
5
6from harness import Albedo, Provider, Reply, python, text
7
8
9class PythonToolsTests(unittest.TestCase):
10    def setUp(self):
11        self.arguments = None
12
13        def script(request):
14            if request["messages"][-1].get("role") == "user":
15                if self.arguments is not None:
16                    return Reply("python", tool_arguments=self.arguments)
17                return python(self.code)
18            return text("done")
19
20        self.provider = Provider(script)
21        self.addCleanup(self.provider.close)
22        self.app = Albedo(self.provider)
23        self.app.__enter__()
24        self.addCleanup(self.app.__exit__, None, None, None)
25
26    def run_cell(self, code, session):
27        """The result of one prompt's cell, the latest in the session."""
28        self.code = code
29        self.app.prompt(session, "use the Python tools").close()
30        self.app.idle(session)
31        results = [
32            json.loads(part["value"])
33            for entry in self.app.history(session)["items"]
34            if entry["kind"] == "tool_result" and entry["tool"]["name"] == "python"
35            for part in entry["content"]
36            if part["kind"] == "json" and part["field"] == "result"
37        ]
38        self.assertEqual(results[-1]["status"], "ok", results[-1])
39        return results[-1]
40
41    def execute(self, code):
42        return self.run_cell(code, self.app.session())["output"]
43
44    def test_host_replies_preserve_escaped_call_ids_and_argument_errors(self):
45        output = self.execute(
46            "kernel = sys.modules['__main__']\n"
47            "key = 'host reply \" \\\\ λ\\n'\n"
48            "pending = asyncio.get_running_loop().create_future()\n"
49            "kernel.PENDING[key] = pending\n"
50            "try:\n"
51            "    kernel.send({'type': 'call', 'id': key, 'method': 'mail.submit', 'args': []})\n"
52            "    print(json.dumps(await asyncio.wait_for(pending, 5)))\n"
53            "finally:\n    kernel.PENDING.pop(key, None)"
54        )
55        self.assertEqual(
56            json.loads(output),
57            {
58                "ok": False,
59                "code": "invalid",
60                "message": "mail.submit(to, body) takes two strings",
61            },
62        )
63
64    def test_deleting_shadowed_tools_recovers_the_original_bindings(self):
65        (self.app.workspace / "note.txt").write_text("recovered\n")
66        session = self.app.session()
67        output = self.run_cell(
68            "original_tools = (files, run, jobs, cells, output, show_image)\n"
69            "files, run, jobs, cells, output, show_image = range(6)\n"
70            "assert (files, run, jobs, cells, output, show_image) == tuple(range(6))\n"
71            "del files, run, jobs, cells, output, show_image\n"
72            "assert (files, run, jobs, cells, output, show_image) == original_tools\n"
73            "def recovered_read():\n    return files.read('note.txt')\n"
74            "print(recovered_read())",
75            session,
76        )["output"]
77        self.assertIn("recovered", output)
78        output = self.run_cell(
79            "assert (files, run, jobs, cells, output, show_image) == original_tools\n"
80            "print(recovered_read())\n"
81            "import builtins\nassert not hasattr(builtins, 'files')",
82            session,
83        )["output"]
84        self.assertIn("recovered", output)
85
86    def test_ambiguous_edit_requires_a_hint_and_preserves_other_matches(self):
87        note = self.app.workspace / "note.txt"
88        note.write_text("same\nother\nsame\n")
89        output = self.execute(
90            "try:\n    files.edit('note.txt', 'same', 'changed')\n"
91            "except Exception as exc:\n    print('ambiguous:', exc)\n"
92            "files.edit('note.txt', 'same', 'changed', line_hint=3)\n"
93            "print(files.read('note.txt'))"
94        )
95        self.assertEqual(note.read_text(), "same\nother\nchanged\n")
96        self.assertIn("ambiguous:", output)
97        self.assertIn("same", output)
98        self.assertIn("changed", output)
99
100    def test_an_edit_miss_says_when_only_whitespace_differs(self):
101        (self.app.workspace / "note.txt").write_text("if ok:\n    run()\n")
102        output = self.execute(
103            "for old in ('if ok:\\n\\trun()', 'absent'):\n"
104            "    try:\n        files.edit('note.txt', old, 'x')\n"
105            "    except ValueError as error:\n        print(str(error).splitlines()[0])"
106        )
107        lines = output.splitlines()
108        self.assertIn("whitespace differs", lines[0])
109        self.assertNotIn("whitespace differs", lines[1])
110
111    def test_find_searches_workspace_and_returns_context(self):
112        (self.app.workspace / "note.txt").write_text("before\nneedle\nafter\n")
113        output = self.execute(
114            "rows = await files.find('needle', '.', context=1)\n"
115            "print(rows)\nprint(files.read('note.txt', start_line=2, end_line=2))"
116        )
117        self.assertIn("note.txt", output)
118        self.assertIn("needle", output)
119        self.assertIn("before", output)
120        self.assertIn("after", output)
121
122    def test_an_empty_find_says_what_was_searched_and_skipped(self):
123        (self.app.workspace / ".ignore").write_text("ignored.txt\n")
124        (self.app.workspace / "ignored.txt").write_text("needle\n")
125        (self.app.workspace / ".dot.txt").write_text("needle\n")
126        (self.app.workspace / "plain.txt").write_text("nothing\n")
127        output = self.execute(
128            "print(await files.find('needle', '.'))\n"
129            "print(await files.find('needle', '.', hidden=True, ignored=True))"
130        )
131        self.assertIn("no matches for 'needle'", output)
132        self.assertIn("1 files searched", output)
133        self.assertIn("skipped hidden files, .gitignore/.ignore rules", output)
134        self.assertIn("ignored.txt", output)
135        self.assertIn("rerun with hidden=True and/or ignored=True", output)
136        self.assertEqual(output.count("no matches"), 1, output)
137
138    def test_find_in_a_missing_root_raises_instead_of_returning_nothing(self):
139        output = self.execute(
140            "try:\n    await files.find('needle', 'nowhere')\n"
141            "except FileNotFoundError as exc:\n    print('missing:', exc)"
142        )
143        self.assertIn("missing: nowhere not found", output)
144
145    def test_a_file_result_is_awaitable_and_offers_content_and_text(self):
146        (self.app.workspace / "note.txt").write_text("hello\n")
147        output = self.execute(
148            "text = await files.read('note.txt')\n"
149            "print('awaited:', text.strip())\n"
150            "print('content:', (await files.read('note.txt')).content.strip())\n"
151            "print('text:', files.read('note.txt').text.strip())"
152        )
153        self.assertIn("awaited: 1 | hello", output)
154        self.assertIn("content: 1 | hello", output)
155        self.assertIn("text: 1 | hello", output)
156
157    def test_paths_searches_a_list_of_directories_with_a_list_of_globs(self):
158        for directory, name in [("a", "one.md"), ("b", "two.txt"), ("c", "three.md")]:
159            (self.app.workspace / directory).mkdir()
160            (self.app.workspace / directory / name).write_text("x")
161        output = self.execute(
162            "rows = await files.paths(None, ['a', 'b'], glob=['*.md', '*.txt'])\n"
163            "print(sorted(str(row) for row in rows))"
164        )
165        self.assertIn("one.md", output)
166        self.assertIn("two.txt", output)
167        self.assertNotIn("three.md", output)
168
169    def test_paths_globs_a_path_from_the_workspace_root(self):
170        (self.app.workspace / "src" / "deep").mkdir(parents=True)
171        (self.app.workspace / "src" / "deep" / "stream_test.go").write_text("x")
172        output = self.execute(
173            "print([str(row) for row in await files.paths('src/*/*_test.go')])\n"
174            "print([str(row) for row in await files.paths('./src/*/*_test.go')])"
175        )
176        self.assertEqual(output.count("['src/deep/stream_test.go']"), 2, output)
177
178    def test_file_reads_decode_complete_lines_across_chunk_boundaries(self):
179        fixtures = [
180            (b"", []),
181            (b"\n\n", ["", ""]),
182            (b"a\r\r\n", ["a", ""]),
183            (b"a\n", ["a"]),
184            (b"a\nlast", ["a", "last"]),
185            (
186                "a\nb\rc\r\nd\ve\ff\x1cg\x1dh\x1ei\x85j\u2028k\u2029last".encode(),
187                list("abcdefghijk") + ["last"],
188            ),
189            (b"a\xffb\xe2\x82", ["a\ufffdb\ufffd"]),
190        ]
191        for padding in (65_534, 65_535, 65_536):
192            prefix = b"x" * padding
193            for separator in (
194                b"\r\n",
195                "\x85".encode(),
196                "\u2028".encode(),
197                "\u2029".encode(),
198            ):
199                fixtures.append((prefix + separator + b"tail", ["x" * padding, "tail"]))
200            fixtures.append((prefix + "é\n尾".encode(), ["x" * padding + "é", "尾"]))
201            fixtures.append(
202                (
203                    prefix + b"\xe2\x82\nend\xe2\x82",
204                    ["x" * padding + "\ufffd", "end\ufffd"],
205                )
206            )
207        for index, (data, _lines) in enumerate(fixtures):
208            (self.app.workspace / f"decode-{index}.txt").write_bytes(data)
209        # Keep large expected lines inside the kernel, out of the tool output.
210        small_expected = [lines for _data, lines in fixtures[:7]]
211        output = self.execute(
212            f"expected = {small_expected!r}\n"
213            "for padding in (65534, 65535, 65536):\n"
214            "    expected.extend([['x' * padding, 'tail']] * 4)\n"
215            "    expected.append(['x' * padding + 'é', '尾'])\n"
216            "    expected.append(['x' * padding + '\ufffd', 'end\ufffd'])\n"
217            "for index, lines in enumerate(expected):\n"
218            "    result = files.read(f'decode-{index}.txt', max_chars=200000)\n"
219            "    numbered = '\\n'.join(f'{number:>6} | {line}' for number, line in enumerate(lines, 1))\n"
220            "    if lines:\n"
221            "        assert result == numbered, (index, len(result), len(numbered))\n"
222            "    else:\n"
223            "        assert ' | ' not in result\n"
224            "        assert all(detail in result for detail in ('decode-0.txt', '0 lines', 'line 1'))\n"
225            "print('decoded complete lines')"
226        )
227        self.assertEqual(output.strip(), "decoded complete lines")
228
229    def test_file_reads_enforce_budgets_and_character_limit_precedence(self):
230        (self.app.workspace / "budget.txt").write_text("a\nbb\nc\nd\n")
231        (self.app.workspace / "numbered.txt").write_text("\n" * 999_999 + "x\nz")
232        output = self.execute(
233            "first = '     1 | a'\n"
234            "second = '     2 | bb'\n"
235            "both = first + '\\n' + second\n"
236            "assert files.read('budget.txt', end_line=2, max_chars=23) == both\n"
237            "assert files.read('budget.txt', end_line=1, max_chars=11) == first\n"
238            "rejected = files.read('budget.txt', max_chars=10)\n"
239            "assert ' | ' not in rejected\n"
240            "assert all(detail in rejected for detail in ('line 1', '10 characters', 'max_chars=10', 'start_line=1', 'end_line=1', 'max_chars=11'))\n"
241            "for budget, shown, unshown, resume in ((22, [first], 'lines 2-4', 2), (23, [first, second], 'lines 3-4', 3)):\n"
242            "    result = files.read('budget.txt', max_chars=budget).splitlines()\n"
243            "    assert result[:-1] == shown\n"
244            "    assert all(detail in result[-1] for detail in (f'max_chars={budget}', unshown, f'start_line={resume}'))\n"
245            "result = files.read('budget.txt', limit=2, max_chars=23).splitlines()\n"
246            "assert result[:-1] == [first, second]\n"
247            "assert all(detail in result[-1] for detail in ('limit=2', '2 more through line 4', 'start_line=3'))\n"
248            "result = files.read('budget.txt', limit=3, max_chars=23).splitlines()\n"
249            "assert result[:-1] == [first, second]\n"
250            "assert all(detail in result[-1] for detail in ('max_chars=23', 'lines 3-3', 'start_line=3'))\n"
251            "assert 'limit=' not in result[-1]\n"
252            "assert files.read('numbered.txt', start_line=1000000, end_line=1000000, max_chars=12) == '1000000 | x'\n"
253            "rejected = files.read('numbered.txt', start_line=1000000, end_line=1000000, max_chars=11)\n"
254            "assert ' | ' not in rejected\n"
255            "assert all(detail in rejected for detail in ('line 1000000', '11 characters', 'max_chars=11', 'start_line=1000000', 'end_line=1000000', 'max_chars=12'))\n"
256            "print('budgets and numbering checked')"
257        )
258        self.assertEqual(output.strip(), "budgets and numbering checked")
259
260    def test_file_reads_count_requested_windows_and_eof_exactly(self):
261        (self.app.workspace / "window.txt").write_text("a\nb\nc\nd")
262        pattern = "a\r\nb\rc\nd\ve\ff\x1cg\x1dh\x1ei\x85j\u2028k\u2029last\n".encode()
263        (self.app.workspace / "bulk.txt").write_bytes(
264            b"head\r\n" + pattern * 10_000 + b"tail\xe2\x82"
265        )
266        output = self.execute(
267            "assert files.read('window.txt', start_line=2, end_line=3) == '     2 | b\\n     3 | c'\n"
268            "assert files.read('window.txt', start_line=3, end_line=99) == '     3 | c\\n     4 | d'\n"
269            "for options, row, remaining, last, resume in (({'start_line': 2}, '     2 | b', 2, 4, 3), ({'start_line': 2, 'end_line': 3}, '     2 | b', 1, 3, 3), ({'start_line': 3, 'end_line': 99}, '     3 | c', 1, 4, 4)):\n"
270            "    result = files.read('window.txt', limit=1, **options).splitlines()\n"
271            "    assert result[:-1] == [row]\n"
272            "    assert all(detail in result[-1] for detail in ('limit=1', f'{remaining} more through line {last}', f'start_line={resume}'))\n"
273            "assert files.read('window.txt', start_line=4, limit=1) == '     4 | d'\n"
274            "empty = files.read('window.txt', start_line=5, end_line=99)\n"
275            "assert all(detail in empty for detail in ('window.txt', '4 lines', 'line 5'))\n"
276            "assert ' | ' not in empty\n"
277            "result = files.read('bulk.txt', start_line=2, limit=1).splitlines()\n"
278            "assert result[:-1] == ['     2 | a']\n"
279            "assert all(detail in result[-1] for detail in ('limit=1', '120000 more through line 120002', 'start_line=3'))\n"
280            "result = files.read('bulk.txt', start_line=2, end_line=100000, limit=1).splitlines()\n"
281            "assert result[:-1] == ['     2 | a']\n"
282            "assert all(detail in result[-1] for detail in ('limit=1', '99998 more through line 100000', 'start_line=3'))\n"
283            "result = files.read('bulk.txt', start_line=120001, limit=1).splitlines()\n"
284            "assert result[:-1] == ['120001 | last']\n"
285            "assert all(detail in result[-1] for detail in ('limit=1', '1 more through line 120002', 'start_line=120002'))\n"
286            "assert files.read('bulk.txt', start_line=120001, end_line=120001) == '120001 | last'\n"
287            "assert files.read('bulk.txt', start_line=120002) == '120002 | tail\\ufffd'\n"
288            "print('window counts checked')"
289        )
290        self.assertEqual(output.strip(), "window counts checked")
291
292    def test_file_reads_count_giant_lines_without_shortening_them(self):
293        (self.app.workspace / "giant.txt").write_text(
294            "head\n" + "x" * 1_048_576 + "\ntail\n"
295        )
296        (self.app.workspace / "unicode.txt").write_text("é" * 110_000)
297        output = self.execute(
298            "assert files.read('giant.txt', start_line=3) == '     3 | tail'\n"
299            "assert files.read('giant.txt', end_line=1) == '     1 | head'\n"
300            "rejected = files.read('giant.txt', start_line=2, end_line=2)\n"
301            "assert ' | ' not in rejected\n"
302            "assert all(detail in rejected for detail in ('line 2', '1048585 characters', 'max_chars=16000', '200000'))\n"
303            "assert 'max_chars=1048586' not in rejected\n"
304            "result = files.read('giant.txt', max_chars=32).splitlines()\n"
305            "assert result[:-1] == ['     1 | head']\n"
306            "assert all(detail in result[-1] for detail in ('max_chars=32', 'lines 2-3', 'start_line=2'))\n"
307            "result = files.read('giant.txt', limit=1).splitlines()\n"
308            "assert result[:-1] == ['     1 | head']\n"
309            "assert all(detail in result[-1] for detail in ('limit=1', '2 more through line 3', 'start_line=2'))\n"
310            "for budget in (16000, 110009):\n"
311            "    rejected = files.read('unicode.txt', max_chars=budget)\n"
312            "    assert ' | ' not in rejected\n"
313            "    assert all(detail in rejected for detail in ('line 1', '110009 characters', f'max_chars={budget}', 'start_line=1', 'end_line=1', 'max_chars=110010'))\n"
314            "assert files.read('unicode.txt', max_chars=110010) == '     1 | ' + 'é' * 110000\n"
315            "print('giant lines counted and returned intact')"
316        )
317        self.assertEqual(output.strip(), "giant lines counted and returned intact")
318
319    def test_file_reads_validate_ranges_before_opening_and_report_path_errors(self):
320        output = self.execute(
321            "for options in ({'start_line': 0}, {'start_line': 2, 'end_line': 1}, {'limit': 0}, {'max_chars': 0}, {'max_chars': 200001}):\n"
322            "    try:\n"
323            "        files.read('absent.txt', **options)\n"
324            "    except ValueError:\n"
325            "        pass\n"
326            "    else:\n"
327            "        raise AssertionError(options)\n"
328            "try:\n"
329            "    files.read('absent.txt')\n"
330            "except FileNotFoundError as error:\n"
331            "    assert 'absent.txt' in str(error)\n"
332            "else:\n"
333            "    raise AssertionError('missing file accepted')\n"
334            "try:\n"
335            "    files.read('.')\n"
336            "except IsADirectoryError as error:\n"
337            "    assert \"files.ls('.')\" in str(error)\n"
338            "else:\n"
339            "    raise AssertionError('directory accepted')\n"
340            "print('range and path errors preserved')"
341        )
342        self.assertEqual(output.strip(), "range and path errors preserved")
343
344    def test_cells_get_the_api_a_model_reaches_for(self):
345        output = self.execute(
346            "job = run('echo', 'hello')\n"
347            "await job\n"
348            "assert (await job.tail()).strip() == 'hello'\n"
349            "assert (await job.head(lines=1)).strip() == 'hello'\n"
350            "assert output.read(job.id, 0, 3) == 'hel'\n"
351            "try:\n"
352            "    output.read(job.id, limit=10**7)\n"
353            "except ValueError as error:\n"
354            "    assert 'offset=' in str(error), error\n"
355            "else:\n"
356            "    raise AssertionError('over-cap limit shortened silently')\n"
357            "from albedo import files as imported\n"
358            "assert imported is files\n"
359            "assert re.fullmatch('a+', 'aa') and Path('.').is_dir()\n"
360            "assert hashlib.md5(b'').hexdigest() and json.dumps(os.sep)\n"
361            "try:\n"
362            "    run('true', timeout=120000)\n"
363            "except ValueError as error:\n"
364            "    assert 'seconds' in str(error) and 'milliseconds' in str(error), error\n"
365            "try:\n"
366            "    time.sleep(2)\n"
367            "except PermissionError as error:\n"
368            "    assert 'run()' in str(error) and 'asyncio.sleep' in str(error), error\n"
369            "time.sleep(0.01)\n"
370            "await asyncio.sleep(0.01)\n"
371            "print('api shapes ok')"
372        )
373        self.assertEqual(output.strip(), "api shapes ok")
374
375    def test_a_cell_reports_how_long_it_ran(self):
376        session = self.app.session()
377        slept = self.run_cell("import asyncio\nawait asyncio.sleep(0.3)", session)
378        self.assertGreaterEqual(slept["duration"], 0.3)
379        self.assertLess(slept["duration"], 5)
380        # The journal keeps it, so cells.info reports it after the fact.
381        info = self.run_cell(
382            f"print((await cells.info({slept['cell_id']!r}))['duration'])", session
383        )
384        self.assertEqual(float(info["output"]), slept["duration"])
385
386    def call(self, arguments, session):
387        """The result of one python call made with exactly these arguments."""
388        self.arguments = arguments
389        self.app.prompt(session, "call the Python tool").close()
390        self.app.idle(session)
391        self.arguments = None
392        return [
393            json.loads(part["value"])
394            for entry in self.app.history(session)["items"]
395            if entry["kind"] == "tool_result" and entry["tool"]["name"] == "python"
396            for part in entry["content"]
397            if part["kind"] == "json" and part["field"] == "result"
398        ][-1]
399
400    def test_timeout_is_optional(self):
401        result = self.call({"code": "print('no timeout')"}, self.app.session())
402        self.assertEqual(result["status"], "ok", result)
403        self.assertEqual(result["output"].strip(), "no timeout")
404
405    def test_an_unusable_timeout_keeps_the_code_for_a_rerun(self):
406        session = self.app.session()
407        rejected = self.call(
408            {"code": "open('made.txt', 'w').write('hi')", "timeout_ms": "soon"},
409            session,
410        )
411        self.assertIn("timeout_ms", rejected["error"])
412        self.assertFalse((self.app.workspace / "made.txt").exists())
413        self.run_cell(f"await cells.run({rejected['cell_id']!r})", session)
414        self.assertEqual((self.app.workspace / "made.txt").read_text(), "hi")