okami.mom/albedo

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

Aly Raffaufrefactor(test): share inspection and stream supportcba5ff8

main
30.3 KiB724 linesraw
1"""Transcript paging and what the transcript keeps, through the real daemon."""
2
3from contextlib import closing
4from dataclasses import dataclass
5import http.client
6import http.server
7from collections.abc import Callable
8import json
9import resource
10import socket
11import sqlite3
12import threading
13import time
14import unittest
15import urllib.error
16import urllib.parse
17
18from inspect_support import Inspection
19from harness import Albedo, Provider, Reply, exclusive, operation_id, python, text
20
21# More client connections than a macOS shell's default open-file limit.
22CONNECTIONS = 300
23
24
25def kill_listener_supervisor(app):
26    """Kill the daemon's listener supervisor over Erlang distribution and
27    return its pid: the keeper labels itself `albedo_listener`, and the
28    supervisor is its one proc_lib-started link."""
29    expression = (
30        "[Keeper] = Call(lists, filter, [fun(P) -> "
31        "case catch proc_lib:get_label(P) of {albedo_listener, _} -> true; _ -> false end "
32        "end, Call(erlang, processes, [])]), "
33        "{links, Links} = Call(erlang, process_info, [Keeper, links]), "
34        "[Listener] = Call(lists, filter, "
35        "[fun(P) -> proc_lib:initial_call(P) =/= false end, Links]), "
36        "true = Call(erlang, exit, [Listener, kill]), "
37        'io:format("~w", [Listener])'
38    )
39    return Inspection(app).evaluate(expression).strip()
40
41
42@dataclass
43class HeldCatalog:
44    url: str
45    release: Callable[[], None]
46
47
48def held_catalog():
49    """A local models catalog whose every request waits until it is released."""
50    released = threading.Event()
51
52    class Handler(http.server.BaseHTTPRequestHandler):
53        def log_message(self, format, *args):
54            pass
55
56        def do_GET(self):
57            released.wait()
58
59    server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
60    server.daemon_threads = True
61    threading.Thread(target=server.serve_forever, daemon=True).start()
62
63    def release():
64        released.set()
65        server.shutdown()
66        server.server_close()
67
68    return HeldCatalog(f"http://127.0.0.1:{server.server_port}/api.json", release)
69
70
71def read_response_details(connection):
72    """Read one known-length HTTP response from a raw socket."""
73    head = b""
74    while b"\r\n\r\n" not in head:
75        chunk = connection.recv(65536)
76        if not chunk:
77            raise AssertionError("connection closed before a response")
78        head += chunk
79    lines = head.split(b"\r\n\r\n", 1)
80    body = lines[1] if len(lines) > 1 else b""
81    length = 0
82    headers = {}
83    for line in lines[0].split(b"\r\n")[1:]:
84        name, _, value = line.partition(b":")
85        name = name.strip().lower()
86        headers[name.decode()] = value.strip().decode()
87        if name == b"content-length":
88            length = int(value.strip())
89    while len(body) < length:
90        chunk = connection.recv(65536)
91        if not chunk:
92            raise AssertionError("connection closed mid-body")
93        body += chunk
94    return int(lines[0].split(b" ")[1]), headers, body
95
96
97def read_response(connection):
98    return read_response_details(connection)[0]
99
100
101def refused_request(app, path, headers, *, method="PUT"):
102    """Send only headers and require a refusal and closure within two seconds."""
103    address = urllib.parse.urlsplit(app.base)
104    head = (
105        f"{method} {path} HTTP/1.1\r\n"
106        f"Host: {address.netloc}\r\n"
107        + "".join(f"{name}: {value}\r\n" for name, value in headers.items())
108        + "\r\n"
109    ).encode()
110    with socket.create_connection(
111        (address.hostname, address.port), timeout=2
112    ) as connection:
113        connection.sendall(head)
114        result = read_response_details(connection)
115        try:
116            closed = connection.recv(1) == b""
117        except ConnectionResetError:
118            closed = True
119        if not closed:
120            raise AssertionError("refusal left the connection open")
121        return result
122
123
124def delayed_request(app, method, path, body, headers):
125    """Split the headers and delay the body, then reuse the connection."""
126    address = urllib.parse.urlsplit(app.base)
127    token = app.connection["token"]
128    head = (
129        f"{method} {path} HTTP/1.1\r\n"
130        f"Host: {address.netloc}\r\n"
131        + "".join(f"{name}: {value}\r\n" for name, value in headers.items())
132        + f"Content-Type: application/json\r\n"
133        f"Content-Length: {len(body)}\r\n\r\n"
134    ).encode()
135    connection = socket.create_connection((address.hostname, address.port), timeout=20)
136    try:
137        connection.sendall(head[:17])
138        time.sleep(0.01)
139        connection.sendall(head[17:])
140        time.sleep(0.05)
141        connection.sendall(body)
142        first = read_response(connection)
143        connection.sendall(
144            (
145                f"GET /server HTTP/1.1\r\n"
146                f"Host: {address.netloc}\r\n"
147                f"Authorization: Bearer {token}\r\n\r\n"
148            ).encode()
149        )
150        return first, read_response(connection)
151    finally:
152        connection.close()
153
154
155class DaemonTest(unittest.TestCase):
156    def test_history_pages_recover_older_turns_once(self):
157        provider = Provider(lambda _request: text("answer"))
158        self.addCleanup(provider.close)
159        with Albedo(provider) as app:
160            session = app.session()
161            for message in ("oldest prompt", "middle prompt", "newest prompt"):
162                app.prompt(session, message).close()
163                app.idle(session)
164
165            pages = []
166            next_page = None
167            while True:
168                query = {"limit": 2}
169                if next_page is not None:
170                    query["next"] = next_page
171                with app.api(
172                    f"/sessions/{session}/history?" + urllib.parse.urlencode(query)
173                ) as response:
174                    page = json.load(response)
175                pages.append(page)
176                if page["older"] is None:
177                    break
178                self.assertIsInstance(page["older"], str)
179                self.assertNotEqual(page["older"], next_page)
180                next_page = page["older"]
181            prompts = [
182                [
183                    part["text"]
184                    for entry in page["items"]
185                    if entry["kind"] == "user"
186                    for part in entry["content"]
187                    if part["kind"] == "text"
188                ]
189                for page in pages
190            ]
191            self.assertEqual(
192                [turn for turn in prompts if turn],
193                [["newest prompt"], ["middle prompt"], ["oldest prompt"]],
194            )
195            self.assertIsNone(pages[-1]["older"])
196
197    # exclusive: restarts the daemon
198    @exclusive
199    def test_a_clean_restart_leaves_no_registry_crash_loop(self):
200        # Shutdown closes the store before the VM halts, and the supervisor
201        # may restart the registry inside that window; its init used to panic
202        # on the closed store ten times before the supervisor gave up. What
203        # holds the VM open long enough to lose the race is a models catalog
204        # fetch still in flight, so the restart runs against a local catalog
205        # that never answers; the fixture config itself stays, so later
206        # fixtures keep their provider.
207        provider = Provider(lambda _request: text("answer"))
208        self.addCleanup(provider.close)
209        catalog = held_catalog()
210        self.addCleanup(catalog.release)
211        with Albedo(provider) as app:
212            settings = json.loads((app.home / "extensions.json").read_text())
213            settings["models"] = {"url": catalog.url}
214            (app.home / "extensions.json").write_text(json.dumps(settings))
215            (app.home / "models.json").unlink(missing_ok=True)
216            log = app.home / "daemon.log"
217            before = log.stat().st_size if log.exists() else 0
218            app.restart()
219            app.restart()
220            tail = log.read_text(errors="replace")[before:]
221            for marker in ("Noproc", "reached_max_restart_intensity", "callee exited"):
222                self.assertNotIn(marker, tail)
223
224    # exclusive: writes an unreadable transcript row and restarts the daemon
225    @exclusive
226    def test_an_unreadable_transcript_row_is_healed_at_startup(self):
227        # Startup indexes and classifies rows saved before those columns
228        # existed. One it could not decode stopped the boot, and every later
229        # boot at the same row. It now becomes a note, its bytes set aside.
230        provider = Provider(lambda _request: text("answer"))
231        self.addCleanup(provider.close)
232        unreadable = b"not a transcript item"
233        with Albedo(provider) as app:
234            healthy = app.session()
235            app.prompt(healthy, "hello").close()
236            app.idle(healthy)
237            broken = app.session()
238            with closing(sqlite3.connect(app.home / "albedo.sqlite")) as db:
239                seq = db.execute(
240                    "INSERT INTO transcript(session,payload) VALUES(?,?) RETURNING seq",
241                    (broken, unreadable),
242                ).fetchone()[0]
243                db.commit()
244            app.restart()
245            self.assertIn("answer", json.dumps(app.history(healthy)))
246            self.assertIn(
247                f"could not be read and was set aside (row {seq})",
248                json.dumps(app.history(broken)),
249            )
250            with closing(sqlite3.connect(app.home / "albedo.sqlite")) as db:
251                kept = db.execute(
252                    "SELECT seq,session,payload FROM transcript_quarantine"
253                ).fetchall()
254            self.assertEqual(kept, [(seq, broken, unreadable)])
255            app.restart()
256            self.assertIn("answer", json.dumps(app.history(healthy)))
257            # The set-aside bytes belong to their session and go with it.
258            resource = f"/sessions/{broken}?view=configuration"
259            with app.api(resource) as response:
260                validator = response.getheader("ETag")
261                response.read()
262            with app.api(
263                resource, method="DELETE", headers={"If-Match": validator}
264            ) as response:
265                self.assertEqual(json.load(response)["state"], "complete")
266            with closing(sqlite3.connect(app.home / "albedo.sqlite")) as db:
267                self.assertEqual(
268                    db.execute("SELECT COUNT(*) FROM transcript_quarantine").fetchone(),
269                    (0,),
270                )
271
272    # exclusive: shuts down and restarts the daemon
273    @exclusive
274    def test_requests_during_a_restart_are_answered_not_crashed(self):
275        # Shutdown closes each session and then the store before the VM halts,
276        # and requests keep arriving meanwhile: ones queued behind the drain
277        # used to die with the registry ("callee exited"), and later ones
278        # found no registry at all. Kernels make the drain take a while, a
279        # held catalog fetch keeps the VM up past the store, and a second
280        # shutdown must not start a second drain.
281        def script(request):
282            called = any(
283                message.get("role") == "tool" for message in request["messages"]
284            )
285            return text("done") if called else python("x = 1")
286
287        provider = Provider(script)
288        self.addCleanup(provider.close)
289        catalog = held_catalog()
290        self.addCleanup(catalog.release)
291        with Albedo(provider) as app:
292            settings = json.loads((app.home / "extensions.json").read_text())
293            settings["models"] = {"url": catalog.url}
294            (app.home / "extensions.json").write_text(json.dumps(settings))
295            (app.home / "models.json").unlink(missing_ok=True)
296            sessions = [app.session() for _ in range(3)]
297            for session in sessions:
298                app.prompt(session, "keep a variable").close()
299            for session in sessions:
300                app.idle(session)
301            log = app.home / "daemon.log"
302            before = log.stat().st_size if log.exists() else 0
303
304            stop = threading.Event()
305            paths = [
306                ("GET", "/sessions", None),
307                ("GET", "/providers/fixture/models", None),
308            ]
309            for session in sessions:
310                paths += [
311                    ("GET", f"/sessions/{session}?tail=0", None),
312                    ("GET", f"/sessions/{session}/history?view=checkpoints", None),
313                    ("GET", f"/sessions?parent_id={session}", None),
314                    ("GET", f"/sessions/{session}?view=configuration", None),
315                ]
316
317            # One kept-alive connection per worker, reopened only when the
318            # daemon drops it: a fresh socket per request exhausts the host's
319            # ephemeral ports within seconds, which fails every other
320            # connection on the machine, live sessions included.
321            def hammer(base, token):
322                address = urllib.parse.urlsplit(base)
323                headers = {
324                    "Authorization": "Bearer " + token,
325                    "Content-Type": "application/json",
326                }
327                connection = None
328                while not stop.is_set():
329                    for method, path, body in paths:
330                        try:
331                            connection = connection or http.client.HTTPConnection(
332                                address.hostname, address.port, timeout=20
333                            )
334                            payload = None if body is None else json.dumps(body)
335                            connection.request(method, path, payload, headers)
336                            connection.getresponse().read()
337                        except (http.client.HTTPException, OSError):
338                            if connection:
339                                connection.close()
340                            connection = None
341                            stop.wait(0.01)
342                if connection:
343                    connection.close()
344
345            workers = [
346                threading.Thread(
347                    target=hammer, args=(app.base, app.connection["token"])
348                )
349                for _ in range(6)
350            ]
351            for worker in workers:
352                worker.start()
353            try:
354                # restart() asks again while the first drain runs.
355                with app.api("/server") as response:
356                    instance_id = json.load(response)["instance_id"]
357                app.api("/server/shutdown", {"instance_id": instance_id}).close()
358                app.restart()
359            finally:
360                stop.set()
361                for worker in workers:
362                    worker.join()
363            tail = log.read_text(errors="replace")[before:]
364            for marker in (
365                "Noproc",
366                "callee exited",
367                "Callee subject had no owner",
368                "reached_max_restart_intensity",
369            ):
370                self.assertNotIn(marker, tail)
371
372    def test_a_late_request_body_keeps_the_keep_alive_connection(self):
373        # mist parses every later TCP read as the next keep-alive request, so
374        # a route that answers without reading its request body leaves those
375        # bytes to that parser: a body arriving after its headers reads as a
376        # new request, fails, and the connection closes without a response,
377        # taking the client's next request with it. The router reads the body
378        # up front, so a split write cannot cost the connection.
379        provider = Provider(lambda _request: text("answer"))
380        self.addCleanup(provider.close)
381        with Albedo(provider) as app:
382            token = app.connection["token"]
383            session = app.session()
384            interrupt = f"/sessions/{session}/interrupt"
385
386            # Every shape that answers without reading its body: a plain
387            # route, an ignored POST body, an unknown route, and a route that
388            # answers a session actor call.
389            for method, path, body, status in (
390                ("GET", "/server", b"{}", 200),
391                ("GET", "/nope", b"{}", 404),
392                ("PUT", f"/sessions/{session}/visits/{operation_id()}", b"{}", 200),
393                ("POST", "/nope", b"{}", 404),
394                ("POST", interrupt, b'{"run_id":null,"through_input_order":0}', 200),
395            ):
396                with self.subTest(f"{method} {path}"):
397                    self.assertEqual(
398                        delayed_request(
399                            app,
400                            method,
401                            path,
402                            body,
403                            {"Authorization": "Bearer " + token},
404                        ),
405                        (status, 200),
406                    )
407
408    def test_empty_and_incomplete_bodies_are_handled_at_ingress(self):
409        with Albedo() as app:
410            token = {"Authorization": "Bearer " + app.connection["token"]}
411            session = app.session()
412            before = app.history(session)
413            self.assertEqual(
414                delayed_request(app, "GET", "/server", b"", token), (200, 200)
415            )
416            address = urllib.parse.urlsplit(app.base)
417            with socket.create_connection(
418                (address.hostname, address.port), timeout=2
419            ) as connection:
420                connection.sendall(
421                    (
422                        f"PUT /sessions/{session}/inputs/{operation_id()} HTTP/1.1\r\n"
423                        f"Host: {address.netloc}\r\n"
424                        f"Authorization: {token['Authorization']}\r\n"
425                        "Content-Length: 2\r\n\r\n{"
426                    ).encode()
427                )
428                connection.shutdown(socket.SHUT_WR)
429                # Mist closes the socket when its body read encounters EOF.
430                self.assertEqual(connection.recv(1), b"")
431            self.assertEqual(app.history(session), before)
432
433    def test_core_refuses_before_reading_withheld_bodies(self):
434        provider = Provider(lambda _request: text("answer"))
435        self.addCleanup(provider.close)
436        with Albedo(provider) as app:
437            token = {"Authorization": "Bearer " + app.connection["token"]}
438            session = app.session()
439            before = app.history(session)
440            path = f"/sessions/{session}/inputs/{operation_id()}"
441            for authorization in ({}, {"Authorization": "Bearer wrong"}):
442                for length in ("2", "72200001", "invalid"):
443                    with self.subTest(authorization=authorization, length=length):
444                        status, headers, body = refused_request(
445                            app, path, {**authorization, "Content-Length": length}
446                        )
447                        self.assertEqual(status, 401)
448                        self.assertEqual(json.loads(body)["status"], 401)
449                        self.assertEqual(headers["www-authenticate"].lower(), "bearer")
450            for framing in (
451                {"Content-Length": "72200001"},
452                {"Content-Length": "invalid"},
453                {"Transfer-Encoding": "chunked"},
454            ):
455                with self.subTest(origin_framing=framing):
456                    status, _, _ = refused_request(
457                        app,
458                        path,
459                        {**token, **framing, "Origin": "https://attacker.example"},
460                    )
461                    self.assertEqual(status, 403)
462            self.assertEqual(app.history(session), before)
463            self.assertFalse(provider.requests)
464
465    def test_core_rejects_unsupported_and_oversized_body_framing(self):
466        with Albedo() as app:
467            session = app.session()
468            before = app.history(session)
469            token = {"Authorization": "Bearer " + app.connection["token"]}
470            for framing, expected_status in (
471                ({"Transfer-Encoding": "chunked", "Content-Length": "2"}, 400),
472                ({"Transfer-Encoding": "identity", "Content-Length": "0"}, 400),
473                ({"Content-Length": "-1"}, 400),
474                ({"Content-Length": "+1"}, 400),
475                ({"Content-Length": "1x"}, 400),
476                ({"Content-Length": "72200001"}, 413),
477            ):
478                with self.subTest(framing=framing):
479                    status, _, body = refused_request(
480                        app,
481                        f"/sessions/{session}/inputs/{operation_id()}",
482                        {**token, **framing},
483                    )
484                    self.assertEqual(status, expected_status)
485                    if framing.get("Transfer-Encoding") == "identity":
486                        self.assertEqual(json.loads(body)["code"], "invalid_request")
487            self.assertEqual(app.history(session), before)
488
489            input_id = operation_id()
490            resource = f"/sessions/{session}/inputs/{input_id}"
491            payload = {"kind": "message", "text": "identity encoded input"}
492            status, _, _ = refused_request(
493                app,
494                resource,
495                {"Content-Encoding": "gzip", "Content-Length": "100"},
496            )
497            self.assertEqual(status, 401)
498            with self.assertRaises(urllib.error.HTTPError) as caught:
499                app.api(
500                    resource,
501                    payload,
502                    method="PUT",
503                    headers={"Content-Encoding": "gzip"},
504                )
505            self.assertEqual(caught.exception.code, 415)
506            self.assertEqual(
507                json.load(caught.exception)["code"], "unsupported_encoding"
508            )
509            self.assertEqual(app.history(session), before)
510            with app.api(
511                resource,
512                payload,
513                method="PUT",
514                headers={"Content-Encoding": "identity"},
515            ) as response:
516                self.assertEqual(response.status, 202)
517            app.idle(session)
518
519    # exclusive: changes daemon startup open-file limits
520    @exclusive
521    def test_a_daemon_started_from_a_macos_shell_answers_hundreds_of_connections(self):
522        # macOS starts a shell at a soft limit of 256 open files. The CLI, like
523        # any Go program, lifts its own limit but starts children on the
524        # original one, and a few hundred client connections would then run
525        # the daemon out of files and take its listener down mid-accept.
526        hard = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
527
528        def prepare(app):
529            app.daemon.open_files = (256, hard)
530
531        with Albedo(prepare=prepare) as app:
532            headers = {"Authorization": "Bearer " + app.connection["token"]}
533            connections = []
534            for _ in range(CONNECTIONS):
535                connection = http.client.HTTPConnection(
536                    "127.0.0.1", app.connection["port"], timeout=20
537                )
538                connection.connect()
539                self.addCleanup(connection.close)
540                connections.append(connection)
541            for connection in connections:
542                connection.request("GET", "/server", headers=headers)
543                response = connection.getresponse()
544                response.read()
545                self.assertEqual(response.status, 200)
546
547    # exclusive: changes daemon open-file limits and crashes its listener
548    @exclusive
549    def test_a_listener_out_of_files_comes_back_on_the_daemons_port(self):
550        # Accepts past the open-file limit crash glisten's acceptors until mist
551        # restarts the whole listener, on the port it was built with. A port
552        # the OS picks at each start would bring it back somewhere daemon.json
553        # does not say, leaving a live daemon no client can reach.
554        def prepare(app):
555            app.daemon.open_files = (128, 128)
556
557        with Albedo(prepare=prepare) as app:
558            pid, port = app.connection["pid"], app.connection["port"]
559            flood = []
560            for _ in range(CONNECTIONS):
561                try:
562                    flood.append(
563                        socket.create_connection(("127.0.0.1", port), timeout=20)
564                    )
565                except OSError:
566                    pass
567            # Nothing the daemon sends ends these sockets: each one ends when
568            # the restarted listener's tree drops the connection it accepted,
569            # or resets the one still waiting in its old socket's backlog.
570            for connection in flood:
571                try:
572                    self.assertEqual(connection.recv(1), b"")
573                except ConnectionResetError:
574                    pass
575                connection.close()
576
577            record = json.loads((app.home / "daemon.json").read_text())
578            self.assertEqual((record["pid"], record["port"]), (pid, port))
579            deadline = time.monotonic() + 20
580            while True:
581                try:
582                    with app.api("/server") as response:
583                        self.assertEqual(response.status, 200)
584                    break
585                except OSError:
586                    self.assertLess(
587                        time.monotonic(), deadline, "the daemon's port stayed closed"
588                    )
589                    time.sleep(0.1)
590
591    # exclusive: enables daemon inspection and kills the listener's supervisor
592    @exclusive
593    def test_the_daemon_outlives_its_listener_supervisor(self):
594        # A listener tree that gives up (every acceptor crashing under a flood,
595        # say) must not take the daemon with it: the keeper brings the listener
596        # back on the same port while sessions and kernels carry on.
597        with Albedo(
598            prepare=lambda app: app.daemon.env.update(ALBEDO_INSPECT="1")
599        ) as app:
600            pid, port = app.connection["pid"], app.connection["port"]
601            killed = kill_listener_supervisor(app)
602            self.assertTrue(killed.startswith("<"), killed)
603            deadline = time.monotonic() + 15
604            while True:
605                try:
606                    with app.api("/server") as response:
607                        self.assertEqual(response.status, 200)
608                    break
609                except OSError:
610                    self.assertLess(
611                        time.monotonic(), deadline, "the daemon's port stayed closed"
612                    )
613                    time.sleep(0.1)
614            record = json.loads((app.home / "daemon.json").read_text())
615            self.assertEqual((record["pid"], record["port"]), (pid, port))
616
617    def test_a_thoughts_duration_is_kept_with_the_transcript(self):
618        # summarized thinking streams once it is written: here the response
619        # opens, thinks 0.6s, and its summary comes 0.3s before the answer,
620        # so the thought is timed from the opening, not the summary
621        def chunk(delta, finish=None):
622            return {
623                "id": "fixture",
624                "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
625            }
626
627        reply = Reply(
628            "text",
629            delay=0.3,
630            events=[
631                chunk({"role": "assistant"}),
632                chunk({"reasoning_content": "weighing it"}),
633                chunk({"content": "answer"}),
634                chunk({}, "stop"),
635            ],
636        )
637        provider = Provider(lambda _request: reply)
638        self.addCleanup(provider.close)
639        with Albedo(provider) as app:
640            session = app.session()
641            before = app.stream_page(session)
642            app.prompt(session, "think first").close()
643            app.idle(session)
644
645            replay = app.stream_page(session, before)
646            thoughts = [
647                event["data"]
648                for event in replay["events"]
649                if event["type"] == "thinking"
650            ]
651            self.assertEqual(
652                "".join(thought["text"] for thought in thoughts), "weighing it"
653            )
654            [published] = [
655                event["data"]["entry"]
656                for event in replay["events"]
657                if event["type"] == "message"
658                and event["data"]["entry"]["kind"] == "thinking"
659            ]
660            self.assertGreaterEqual(published["thinking_duration_ms"], 500)
661            [durable] = [
662                entry
663                for entry in app.history(session)["items"]
664                if entry["kind"] == "thinking"
665            ]
666            self.assertEqual(
667                "".join(
668                    part["text"]
669                    for part in durable["content"]
670                    if part["kind"] == "text"
671                ),
672                "weighing it",
673            )
674            self.assertGreaterEqual(durable["thinking_duration_ms"], 500)
675            self.assertEqual(published["id"], durable["id"])
676
677    def test_chunked_input_admission_counts_decoded_body_and_keeps_connection(self):
678        provider = Provider(lambda _: text("chunked answer"))
679        self.addCleanup(provider.close)
680        with Albedo(provider) as app:
681            session = app.session()
682            input_id = operation_id()
683            intent = {"kind": "message", "text": "chunked Café 😀"}
684            payload = json.dumps(intent, ensure_ascii=False).encode()
685            chunks = [payload[index : index + 3] for index in range(0, len(payload), 3)]
686            connection = http.client.HTTPConnection(
687                "127.0.0.1", app.connection["port"], timeout=20
688            )
689            self.addCleanup(connection.close)
690            headers = {
691                "Authorization": "Bearer " + app.connection["token"],
692                "Content-Type": "application/json",
693            }
694            connection.request(
695                "PUT",
696                f"/sessions/{session}/inputs/{input_id}",
697                chunks,
698                headers,
699                encode_chunked=True,
700            )
701            response = connection.getresponse()
702            self.assertEqual(response.status, 202)
703            self.assertEqual(json.load(response)["id"], input_id)
704            connection.request("GET", "/server", headers=headers)
705            response = connection.getresponse()
706            self.assertEqual(response.status, 200)
707            self.assertEqual(json.load(response)["state"], "ready")
708            app.idle(session)
709            users = [
710                entry
711                for entry in app.history(session)["items"]
712                if entry["kind"] == "user"
713            ]
714            self.assertEqual([entry["input_id"] for entry in users], [input_id])
715            self.assertEqual(
716                [
717                    part["text"]
718                    for entry in users
719                    for part in entry["content"]
720                    if part["kind"] == "text"
721                ],
722                [intent["text"]],
723            )
724            self.assertEqual(len(provider.requests), 1)