diff --git a/strix/tools/output_store.py b/strix/tools/output_store.py index 88704b50..66454236 100644 --- a/strix/tools/output_store.py +++ b/strix/tools/output_store.py @@ -258,24 +258,32 @@ def read_stored_output(output_id: str, *, offset: int = 0, limit: int = _PAGE_MA start = _boundary_offset(path, max(0, offset)) if offset > 0 else 0 if start >= size: return "" - # Reserve the continuation hint's bytes out of the effective ceiling so the - # *complete* response (content + hint) honours both the caller's ``limit`` - # and the global page cap — retrieval bypasses the result-bounding wrapper, - # so this is the only ceiling. next_offset can never exceed the file size, so - # formatting with ``size`` is an exact upper bound on the hint length. Floor - # content at 4 bytes (the max UTF-8 char length) so a page always makes - # progress past one char even when ``limit`` is smaller than the hint. + + effective = min(max(4, limit), _PAGE_MAX_BYTES) + # A final page carries no continuation hint, so the whole remaining output + # can use the budget and any limit is honoured exactly. + if size - start <= effective: + with path.open("rb") as handle: + handle.seek(start) + return handle.read().decode("utf-8", errors="replace") + + # A non-final page's complete response is content + a continuation hint, and + # the whole thing must fit ``effective``. next_offset can never exceed the + # file size, so formatting with ``size`` is an exact upper bound on the hint. + # A page needs at least one char (4 bytes) of content to make progress, so a + # limit too small to hold that plus the hint is rejected rather than silently + # exceeded. hint_reserve = len(_CONTINUATION_HINT.format(output_id=output_id, offset=size)) - content_budget = max(4, min(max(4, limit), _PAGE_MAX_BYTES) - hint_reserve) + content_budget = effective - hint_reserve + if content_budget < 4: + return ( + f"limit={limit} is too small to page this output; " + f"request at least {hint_reserve + 4} bytes." + ) with path.open("rb") as handle: handle.seek(start) - chunk = handle.read(content_budget) - - has_more = start + len(chunk) < size - if has_more: - chunk = _trim_incomplete_utf8_tail(chunk) - shown = chunk.decode("utf-8", errors="replace") - if has_more: - next_offset = start + len(chunk) - shown += _CONTINUATION_HINT.format(output_id=output_id, offset=next_offset) - return shown + chunk = _trim_incomplete_utf8_tail(handle.read(content_budget)) + next_offset = start + len(chunk) + return chunk.decode("utf-8", errors="replace") + _CONTINUATION_HINT.format( + output_id=output_id, offset=next_offset + ) diff --git a/tests/test_output_store.py b/tests/test_output_store.py index 08885b7d..1899ac52 100644 --- a/tests/test_output_store.py +++ b/tests/test_output_store.py @@ -130,6 +130,20 @@ def test_read_stored_output_paginates(tmp_path: Path) -> None: assert match is not None and int(match.group(1)) > 0 +def test_read_stored_output_rejects_limit_too_small_for_a_page(tmp_path: Path) -> None: + # A non-final page needs room for content plus the continuation hint; a limit + # too small to hold both is rejected rather than silently exceeded. + configure_output_store(tmp_path) + text = "\n".join(str(i) for i in range(1000)) + output_id = re.search( + r'output_id="([0-9a-f]{32})"', + bound_and_store(text, max_lines=4, max_bytes=1_000_000), + ) + assert output_id is not None + result = read_stored_output(output_id.group(1), offset=0, limit=10) + assert "too small" in result + + def test_read_stored_output_page_including_hint_stays_within_ceiling(tmp_path: Path) -> None: # A full non-final page plus its continuation hint must not exceed the page # ceiling — retrieval bypasses the general result-bounding wrapper.