switchroom 0.19.18 → 0.19.22

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.
Files changed (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,470 @@
1
+ """Direct coverage of ``HindsightClient.document_exists`` (#3599 review R3-B2).
2
+
3
+ Every drain test patches ``drain_pending._document_state``, so before this
4
+ file the tri-state itself — the thing the docstring calls "load-bearing and
5
+ must not be collapsed to a bool" — had ZERO executed lines and was asserted
6
+ only in prose. ``return False if e.code == 404 else None`` could be mutated
7
+ to a bare ``return True`` and both suites stayed green.
8
+
9
+ These run against a REAL local HTTP server rather than a mocked ``urllib``:
10
+ the branch under test is precisely the exception taxonomy urllib raises
11
+ (``HTTPError`` with a code vs ``URLError``/``socket.timeout``), and a mock
12
+ that hand-raises those proves the handler, not that urllib produces them.
13
+
14
+ The tri-state contract:
15
+ * ``True`` — 200, the document is there;
16
+ * ``False`` — 404, and ONLY 404. It is the sole answer that lets a caller
17
+ re-POST (reconcile) or keep an entry queued for phase 2;
18
+ * ``None`` — unknown: any 5xx, any transport error, any timeout.
19
+ Collapsing this to ``True`` retires the last on-disk copy of a turn on
20
+ a flaky GET (#3244 silent loss); collapsing it to ``False`` re-POSTs an
21
+ already-durable document at full LLM extraction cost.
22
+ """
23
+
24
+ import http.server
25
+ import os
26
+ import socket
27
+ import sys
28
+ import threading
29
+ import unittest
30
+ import unittest.mock
31
+
32
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
33
+ if SCRIPTS_DIR not in sys.path:
34
+ sys.path.insert(0, SCRIPTS_DIR)
35
+
36
+ from lib.client import HindsightClient # noqa: E402
37
+
38
+ BANK = "bank-a"
39
+ DOC = "sess-1-rdoc-1"
40
+
41
+
42
+ class _Handler(http.server.BaseHTTPRequestHandler):
43
+ #: set per-server instance; a callable (handler) -> None, or an int status
44
+ behaviour = 200
45
+
46
+ def do_GET(self): # noqa: N802 — BaseHTTPRequestHandler API
47
+ b = self.server.behaviour
48
+ if callable(b):
49
+ b(self)
50
+ return
51
+ self.send_response(b)
52
+ self.send_header("Content-Type", "application/json")
53
+ body = b'{"id": "%s"}' % DOC.encode() if b == 200 else b"{}"
54
+ self.send_header("Content-Length", str(len(body)))
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ def log_message(self, *_a): # silence the stderr access log
59
+ pass
60
+
61
+
62
+ class _Server(http.server.HTTPServer):
63
+ behaviour = 200
64
+
65
+
66
+ class DocumentExistsTriStateTest(unittest.TestCase):
67
+ """200 → True, 404 → False, everything else → None."""
68
+
69
+ def _serve(self, behaviour):
70
+ srv = _Server(("127.0.0.1", 0), _Handler)
71
+ srv.behaviour = behaviour
72
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
73
+ t.start()
74
+ self.addCleanup(t.join, 5)
75
+ self.addCleanup(srv.server_close)
76
+ self.addCleanup(srv.shutdown)
77
+ return HindsightClient(f"http://127.0.0.1:{srv.server_address[1]}")
78
+
79
+ def test_200_is_true(self):
80
+ c = self._serve(200)
81
+ self.assertIs(c.document_exists(BANK, DOC, timeout=5), True)
82
+
83
+ def test_404_is_false(self):
84
+ """The ONLY negative. A 404 is the server stating the document is
85
+ absent, which is the one answer that authorises a re-POST."""
86
+ c = self._serve(404)
87
+ self.assertIs(c.document_exists(BANK, DOC, timeout=5), False)
88
+
89
+ def test_500_is_unknown(self):
90
+ c = self._serve(500)
91
+ self.assertIsNone(c.document_exists(BANK, DOC, timeout=5))
92
+
93
+ def test_503_is_unknown(self):
94
+ """The reviewer's live repro: a degraded upstream answering 503 must
95
+ leave the entry queued, not retire it."""
96
+ c = self._serve(503)
97
+ self.assertIsNone(c.document_exists(BANK, DOC, timeout=5))
98
+
99
+ def test_429_is_unknown(self):
100
+ """Not every non-404 error is a 5xx; rate limiting says nothing
101
+ about presence either."""
102
+ c = self._serve(429)
103
+ self.assertIsNone(c.document_exists(BANK, DOC, timeout=5))
104
+
105
+ def test_timeout_is_unknown(self):
106
+ """A hung server: urllib raises ``socket.timeout``/``URLError``,
107
+ which is neither presence nor absence."""
108
+ import time
109
+
110
+ def hang(handler):
111
+ time.sleep(3)
112
+ try:
113
+ handler.send_response(200)
114
+ handler.end_headers()
115
+ except OSError:
116
+ pass
117
+
118
+ c = self._serve(hang)
119
+ self.assertIsNone(c.document_exists(BANK, DOC, timeout=1))
120
+
121
+ def test_connection_refused_is_unknown(self):
122
+ """Nothing listening at all — the daemon is down, not the document."""
123
+ s = socket.socket()
124
+ s.bind(("127.0.0.1", 0))
125
+ port = s.getsockname()[1]
126
+ s.close()
127
+ c = HindsightClient(f"http://127.0.0.1:{port}")
128
+ self.assertIsNone(c.document_exists(BANK, DOC, timeout=2))
129
+
130
+ def test_malformed_body_on_200_is_still_true(self):
131
+ """Presence is decided by the STATUS, not by parsing the body: a
132
+ proxy that mangles the payload must not read as an absence."""
133
+
134
+ def garbage(handler):
135
+ handler.send_response(200)
136
+ handler.send_header("Content-Length", "9")
137
+ handler.end_headers()
138
+ handler.wfile.write(b"not-json!")
139
+
140
+ c = self._serve(garbage)
141
+ self.assertIs(c.document_exists(BANK, DOC, timeout=5), True)
142
+
143
+
144
+ class DocumentExistsRequestShapeTest(unittest.TestCase):
145
+ """The URL/method/auth the tri-state is derived from."""
146
+
147
+ def test_gets_the_document_path_with_both_ids_url_encoded(self):
148
+ seen = {}
149
+
150
+ def capture(handler):
151
+ seen["path"] = handler.path
152
+ seen["auth"] = handler.headers.get("Authorization")
153
+ seen["method"] = handler.command
154
+ handler.send_response(200)
155
+ handler.send_header("Content-Length", "2")
156
+ handler.end_headers()
157
+ handler.wfile.write(b"{}")
158
+
159
+ srv = _Server(("127.0.0.1", 0), _Handler)
160
+ srv.behaviour = capture
161
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
162
+ t.start()
163
+ self.addCleanup(t.join, 5)
164
+ self.addCleanup(srv.server_close)
165
+ self.addCleanup(srv.shutdown)
166
+
167
+ c = HindsightClient(f"http://127.0.0.1:{srv.server_address[1]}", "tok")
168
+ self.assertIs(c.document_exists("bank/with slash", "doc id", timeout=5), True)
169
+ self.assertEqual(seen["method"], "GET")
170
+ self.assertEqual(
171
+ seen["path"],
172
+ "/v1/default/banks/bank%2Fwith%20slash/documents/doc%20id",
173
+ "both ids must be fully quoted — an unescaped '/' would GET a "
174
+ "different resource and the answer would be about that one",
175
+ )
176
+ self.assertEqual(seen["auth"], "Bearer tok")
177
+
178
+
179
+ class DocumentStateWrapperTest(unittest.TestCase):
180
+ """``drain_pending._document_state`` — the wrapper every drain test
181
+ patches away, and which was therefore never itself executed.
182
+
183
+ It must preserve the tri-state end to end and swallow every exception
184
+ into ``None``: it is called on the retire path, where a raised
185
+ exception escaping into ``drain()``'s per-entry handler would be
186
+ recorded as a POST failure and age the entry toward ``.dead``.
187
+ """
188
+
189
+ def setUp(self):
190
+ """Point the fixture at a LIVE server that answers 200 to anything.
191
+
192
+ The previous fixture used an unreachable ``http://127.0.0.1:1/none``,
193
+ which made ``test_missing_ids_are_unknown_not_absent`` unable to fail
194
+ (#3599 review R4-B1): with the ``if not did or not bank`` guard
195
+ deleted outright, control reached ``document_exists``, the connection
196
+ was refused, and the bare ``except`` returned ``None`` — exactly what
197
+ the test asserts. Both ``or`` → ``and`` AND deleting the guard
198
+ survived the whole suite.
199
+
200
+ A live 200-serving loopback models the real hazard. An entry with an
201
+ empty ``document_id`` quotes to ``""``, so the GET hits the
202
+ COLLECTION endpoint ``/v1/default/banks/<bank>/documents/`` — which a
203
+ real daemon answers **200** — and ``document_exists`` returns
204
+ ``True``. ``drain()`` would then retire a turn whose document was
205
+ never confirmed: the #3244 silent-loss path this PR exists to close.
206
+ """
207
+ self.requests: list[str] = []
208
+
209
+ def count(handler):
210
+ self.requests.append(handler.path)
211
+ body = b'{"id": "whatever"}'
212
+ handler.send_response(200)
213
+ handler.send_header("Content-Type", "application/json")
214
+ handler.send_header("Content-Length", str(len(body)))
215
+ handler.end_headers()
216
+ handler.wfile.write(body)
217
+
218
+ srv = _Server(("127.0.0.1", 0), _Handler)
219
+ srv.behaviour = count
220
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
221
+ t.start()
222
+ self.addCleanup(t.join, 5)
223
+ self.addCleanup(srv.server_close)
224
+ self.addCleanup(srv.shutdown)
225
+ self.api_url = f"http://127.0.0.1:{srv.server_address[1]}"
226
+
227
+ def _entry(self, **kw):
228
+ e = {
229
+ "api_url": self.api_url,
230
+ "api_token": None,
231
+ "bank_id": BANK,
232
+ "document_id": DOC,
233
+ }
234
+ e.update(kw)
235
+ return e
236
+
237
+ def test_forwards_the_client_tri_state_unchanged(self):
238
+ import drain_pending
239
+
240
+ for state in (True, False, None):
241
+ with self.subTest(state=state):
242
+ with unittest.mock.patch.object(
243
+ drain_pending.HindsightClient,
244
+ "document_exists",
245
+ lambda self, b, d, timeout=30: state,
246
+ ):
247
+ self.assertIs(
248
+ drain_pending._document_state(self._entry()), state
249
+ )
250
+
251
+ def test_missing_ids_are_unknown_not_absent(self):
252
+ """No document_id / bank_id means we cannot ask. That is unknown —
253
+ returning ``False`` would re-POST, ``True`` would retire blind.
254
+
255
+ Two assertions, and the SECOND is the one with teeth: the guard must
256
+ return ``None`` *without asking*. Against this test's live 200 server
257
+ an unguarded empty ``document_id`` GETs the collection endpoint and
258
+ reads back ``True``, so "answered None" alone cannot tell "guarded
259
+ early" from "asked and got a refusal".
260
+ """
261
+ import drain_pending
262
+
263
+ for kw in ({"document_id": ""}, {"document_id": None}, {"bank_id": ""},
264
+ {"bank_id": None}):
265
+ with self.subTest(**kw):
266
+ self.requests.clear()
267
+ self.assertIsNone(
268
+ drain_pending._document_state(self._entry(**kw))
269
+ )
270
+ self.assertEqual(
271
+ self.requests,
272
+ [],
273
+ "an entry with no usable ids must not reach the daemon at "
274
+ "all: the collection endpoint answers 200 and would read "
275
+ "back as a confirmed presence",
276
+ )
277
+
278
+ def test_an_empty_document_id_would_confirm_the_collection_endpoint(self):
279
+ """Why the guard exists, stated as an executable fact rather than a
280
+ comment: with the ids blank the URL degrades to the collection
281
+ endpoint, and that answers 200. This is the failure the guard
282
+ prevents, demonstrated one layer down at the client.
283
+ """
284
+ c = HindsightClient(self.api_url)
285
+ self.assertIs(c.document_exists(BANK, "", timeout=5), True)
286
+ self.assertEqual(
287
+ self.requests,
288
+ [f"/v1/default/banks/{BANK}/documents/"],
289
+ "an empty document_id quotes to '' and GETs the COLLECTION",
290
+ )
291
+
292
+ def test_a_raising_client_is_unknown_not_a_crash(self):
293
+ import drain_pending
294
+
295
+ def boom(self, b, d, timeout=30):
296
+ raise RuntimeError("upstream on fire")
297
+
298
+ with unittest.mock.patch.object(
299
+ drain_pending.HindsightClient, "document_exists", boom
300
+ ):
301
+ self.assertIsNone(drain_pending._document_state(self._entry()))
302
+
303
+ def test_a_bad_api_url_is_unknown_not_a_crash(self):
304
+ """``HindsightClient.__init__`` raises on a non-http scheme; an old
305
+ queue entry with a junk url must not take the drain down."""
306
+ import drain_pending
307
+
308
+ self.assertIsNone(
309
+ drain_pending._document_state(self._entry(api_url="ftp://nope/x"))
310
+ )
311
+
312
+
313
+ class HealthCheckRetryBoundaryTest(unittest.TestCase):
314
+ """``client.py``'s ``if attempt < retries`` sleep guard (#3599 R3-L3).
315
+
316
+ Mutating it to ``<=`` survived the whole sweep: it costs one extra
317
+ ``HEALTH_CHECK_DELAY`` sleep after the LAST attempt, for a result that
318
+ is already decided. The SessionStart drain gate passes ``retries=1``
319
+ precisely so a hung server costs ~one timeout and no delay at all; the
320
+ mutant makes that path sleep 2s inside a 4s hook budget.
321
+ """
322
+
323
+ def _client(self, attempts_log, ok_after=None):
324
+ c = HindsightClient("http://127.0.0.1:1/none")
325
+
326
+ def fake_urlopen(req, timeout=None):
327
+ attempts_log.append(timeout)
328
+ if ok_after is not None and len(attempts_log) >= ok_after:
329
+ class _R:
330
+ status = 200
331
+
332
+ def __enter__(self_inner):
333
+ return self_inner
334
+
335
+ def __exit__(self_inner, *a):
336
+ return False
337
+
338
+ return _R()
339
+ raise OSError("refused")
340
+
341
+ return c, fake_urlopen
342
+
343
+ def _run(self, retries, ok_after=None):
344
+ attempts, slept = [], []
345
+ c, fake = self._client(attempts, ok_after)
346
+ import time as _t
347
+
348
+ with unittest.mock.patch("urllib.request.urlopen", fake):
349
+ with unittest.mock.patch.object(_t, "sleep", slept.append):
350
+ result = c.health_check(timeout=1, retries=retries)
351
+ return result, attempts, slept
352
+
353
+ def test_a_single_retry_never_sleeps(self):
354
+ """retries=1 is the hook-budget contract: one shot, no delay."""
355
+ ok, attempts, slept = self._run(retries=1)
356
+ self.assertFalse(ok)
357
+ self.assertEqual(len(attempts), 1)
358
+ self.assertEqual(slept, [], "no sleep after the final attempt")
359
+
360
+ def test_n_attempts_sleep_exactly_n_minus_one_times(self):
361
+ ok, attempts, slept = self._run(retries=3)
362
+ self.assertFalse(ok)
363
+ self.assertEqual(len(attempts), 3)
364
+ self.assertEqual(
365
+ len(slept), 2, "a sleep BETWEEN attempts, never after the last"
366
+ )
367
+
368
+ def test_success_short_circuits_the_remaining_attempts(self):
369
+ ok, attempts, slept = self._run(retries=3, ok_after=2)
370
+ self.assertTrue(ok)
371
+ self.assertEqual(len(attempts), 2)
372
+ self.assertEqual(len(slept), 1)
373
+
374
+ def test_retries_is_floored_at_one(self):
375
+ ok, attempts, slept = self._run(retries=0)
376
+ self.assertEqual(len(attempts), 1, "0 retries still makes one attempt")
377
+ self.assertEqual(slept, [])
378
+
379
+
380
+ class SessionDocumentIdsPagingTest(unittest.TestCase):
381
+ """``session_document_ids`` must page until the server runs out.
382
+
383
+ The loop stops on a SHORT page (``len(items) < page``). With ``<=`` it
384
+ stops on a FULL one too, so a session with exactly one page of
385
+ documents reports only that page — and the #3244 recovery caller reads
386
+ a truncated set as "these turns were never retained" and restores
387
+ duplicates. A sweep found this uncovered: no test ever served more
388
+ than one page.
389
+ """
390
+
391
+ def _serve(self, pages):
392
+ """Serve ``pages`` (a list of lists of ids) by offset."""
393
+ seen = []
394
+
395
+ def handler(h):
396
+ import urllib.parse as up
397
+
398
+ q = up.parse_qs(up.urlparse(h.path).query)
399
+ offset = int(q.get("offset", ["0"])[0])
400
+ limit = int(q.get("limit", ["200"])[0])
401
+ seen.append((offset, limit))
402
+ idx = offset // limit
403
+ items = pages[idx] if idx < len(pages) else []
404
+ body = (
405
+ '{"items": ['
406
+ + ", ".join('{"id": "%s"}' % i for i in items)
407
+ + "]}"
408
+ ).encode()
409
+ h.send_response(200)
410
+ h.send_header("Content-Type", "application/json")
411
+ h.send_header("Content-Length", str(len(body)))
412
+ h.end_headers()
413
+ h.wfile.write(body)
414
+
415
+ srv = _Server(("127.0.0.1", 0), _Handler)
416
+ srv.behaviour = handler
417
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
418
+ t.start()
419
+ self.addCleanup(t.join, 5)
420
+ self.addCleanup(srv.server_close)
421
+ self.addCleanup(srv.shutdown)
422
+ return HindsightClient(f"http://127.0.0.1:{srv.server_address[1]}"), seen
423
+
424
+ def test_a_full_page_is_followed_by_another_request(self):
425
+ c, seen = self._serve([["a", "b"], ["c"]])
426
+ got = c.list_session_document_ids(BANK, "sess-1", page=2, timeout=5)
427
+ self.assertEqual(got, {"a", "b", "c"}, "a full page must not end the walk")
428
+ self.assertEqual([o for o, _ in seen], [0, 2])
429
+
430
+ def test_a_short_page_ends_the_walk(self):
431
+ c, seen = self._serve([["a"], ["never-fetched"]])
432
+ got = c.list_session_document_ids(BANK, "sess-1", page=2, timeout=5)
433
+ self.assertEqual(got, {"a"})
434
+ self.assertEqual(len(seen), 1, "a short page means the server is done")
435
+
436
+ def test_an_exactly_full_last_page_costs_one_empty_confirmation(self):
437
+ """The walk cannot know a full page was the last one without asking."""
438
+ c, seen = self._serve([["a", "b"], []])
439
+ got = c.list_session_document_ids(BANK, "sess-1", page=2, timeout=5)
440
+ self.assertEqual(got, {"a", "b"})
441
+ self.assertEqual(len(seen), 2)
442
+
443
+ def test_max_pages_bounds_the_walk(self):
444
+ c, seen = self._serve([["a", "b"]] * 10)
445
+ c.list_session_document_ids(BANK, "sess-1", page=2, max_pages=3, timeout=5)
446
+ self.assertEqual(len(seen), 3, "max_pages is a hard bound")
447
+
448
+ def test_max_pages_is_floored_at_one(self):
449
+ """``max(1, max_pages)`` — found by the R4-B1 sibling sweep, which
450
+ looked for guards whose only test reaches the same answer by a
451
+ second path. Every existing test passed ``max_pages >= 3``, so
452
+ ``max(1, ...)`` → ``max(0, ...)`` survived: nothing ever asked for
453
+ zero pages. It is not equivalent. ``max_pages=0`` under the mutant
454
+ makes ZERO requests and returns an empty set, and the #3244
455
+ recovery caller reads an empty set as "this session was never
456
+ retained" and restores the whole thing — duplicate restore from a
457
+ config value, with no error anywhere. One page is the floor for the
458
+ same reason ``retries`` is floored in ``health_check``: a bound of
459
+ zero is a typo, not a request to do nothing.
460
+ """
461
+ c, seen = self._serve([["a"], ["never-fetched"]])
462
+ got = c.list_session_document_ids(
463
+ BANK, "sess-1", page=2, max_pages=0, timeout=5
464
+ )
465
+ self.assertEqual(len(seen), 1, "0 pages still makes one request")
466
+ self.assertEqual(got, {"a"})
467
+
468
+
469
+ if __name__ == "__main__":
470
+ unittest.main()