phase: 164_chat_error_honesty
All gates green. Report: - **Verified (phase 164, all 4 tasks already in `complete/`)**: `HistoryTurn`/`ChatMessage`/`ChatThinkingSegment` caps → 600 000, `history`/saved-messages lists → 500, new `SavedChatCreate/Update` total-8 000 000-char validator; exported `errorDetailText` at the two previously unguarded chat paths (pre-stream `!res.ok` + SSE `error` frame); `FAILED_REQUEST_TEXT` + `bubbleText` threading + phase-114 banner hint; dedicated E2E `tests/e2e/test_chat_error_honesty.py`. - **Final-pass additions**: `tests/integration/test_chat_api.py::test_history_rejects_one_char_over_the_text_cap` (endpoint-level >600k → 422 with the pydantic list-of-objects `msg` — the criterion's upper half was only unit-pinned); README env rows for `BOR_APP_VERSION` + `BOR_SESSION_COOKIE`; restored `test_document_viewer.py`'s tracked screenshot artifact dirtied by the E2E run (via read-only `git show`, no index/history touched). - **Tests/lint (exact commands)**: `uv run pytest --cov=app --cov-report=term-missing` → **3837 passed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` → **4 passed in isolation**; per-file loop over **all 148** E2E files (`uv run pytest tests/e2e/<f> -q --no-cov`) → **148 × exit 0**, incl. `test_failed_turn_retry`, `test_chat_save_payload_limits`, `test_edit_and_resend`, `test_llm_history`, `test_smoke`; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - **Completion criteria**: pre-stream 422 shows the distinct line + verbatim `msg`, `[object Object]` unrepresentable + no `input` in DOM — **met** (unit totality/source pins + E2E asserts 1–2); 525k history accepted (200 settles) / >600k → 422 / saved chat 201 + 8M bound — **met**; transport + SSE-frame settles keep `FAILED_TURN_TEXT`, stop/abort untouched, §7.4 re-enables — **met**; budget trim, `message` 4000, the 7 guarded detail-readers and all SSE shapes byte-identical (diff touches only caps + the two chat paths) — **met**; README/AGENTS full review + proxy deploy note — **met**; commit/`complete/` move = pipeline gate. - **Docs**: README reviewed in full (env tables cross-checked programmatically: 74 `BOR_*` rows vs `Settings` defaults — no mismatches; every `.env.example` var documented; referenced scripts/pages/endpoints/`query_log` columns exist) — found & fixed the 2 undocumented real knobs above; the phase's payload-limits section, failed-turn copy and `client_max_body_size ≥ 16 MB` deploy note verified accurate. `AGENTS.md` verified consistent (rules, gitignore list, quick-reference commands, referenced paths all correct) — no changes needed. - **Deviations**: `ChatThinkingSegment.text` raised 32 000 → 600 000 too (not itemized in A1, but its own docstring mirrors `HistoryTurn.thinking`; without it a long-thinking turn 422s its own auto-save — same mirror invariant). No live-infra changes made (the proxy limit lives in the separate deployment repo; README note only). - **Next pending phase**: none — `164_chat_error_honesty` was the only entry in `todo/`.
This commit is contained in:
@@ -376,7 +376,7 @@ def test_failed_funnel_note_and_retry_join_the_actions_row() -> None:
|
||||
header = (
|
||||
"function finalizeFailedTurn(\n"
|
||||
" detail,\n"
|
||||
" { acc, thinking, tools, segments, wrap, leavePartialIndex }\n"
|
||||
" { acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }\n"
|
||||
") {"
|
||||
)
|
||||
body = _fn_body(js, header)
|
||||
|
||||
@@ -924,9 +924,21 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
let detail = `Brain's API answered with HTTP ${res.status}.`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body.detail) detail = body.detail;
|
||||
// Phase 164 (D2): the 422 `detail` is a LIST OF OBJECTS — the
|
||||
// shared serializer yields the `msg` text, never
|
||||
// "[object Object]" and never the rejected `input`.
|
||||
detail = errorDetailText(body.detail, detail);
|
||||
} catch { /* non-JSON error body */ }
|
||||
throw new Error(detail);
|
||||
// Phase 164 (D3, locked A3/A4): a pre-stream HTTP rejection gets
|
||||
// its own honest bubble (the server ANSWERED — it did not drop),
|
||||
// and the phase-114 hint channel: the banner reassures that only
|
||||
// this send died. runTurn's catch threads bubbleText into
|
||||
// finalizeFailedTurn; hint rides setUiState's opts unchanged.
|
||||
const err = new Error(detail);
|
||||
err.bubbleText = FAILED_REQUEST_TEXT;
|
||||
err.hint =
|
||||
"The app answered — it rejected the request before the model ran. Nothing was lost but this send.";
|
||||
throw err;
|
||||
}
|
||||
await readSSE(res, (ev) => {
|
||||
if (aborted) return;
|
||||
@@ -1321,7 +1333,7 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
// Phase 114 (TODO L6): carry the frame's optional hint (the
|
||||
// "question too long" frame has one) through the throw — the
|
||||
// banner shows it in place of the default reachability hint.
|
||||
const err = new Error(ev.detail || "Something went wrong on my side.");
|
||||
const err = new Error(errorDetailText(ev.detail, "Something went wrong on my side."));
|
||||
err.hint = ev.hint;
|
||||
throw err;
|
||||
}
|
||||
@@ -1453,6 +1465,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
|
||||
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
|
||||
wrap,
|
||||
leavePartialIndex,
|
||||
// Phase 164 (D3): the pre-stream HTTP rejection carries its own
|
||||
// honest bubble line (FAILED_REQUEST_TEXT); every other throw
|
||||
// (transport, SSE error frame) leaves it undefined → the
|
||||
// zero-frame settle keeps FAILED_TURN_TEXT byte-identically.
|
||||
bubbleText: err?.bubbleText,
|
||||
});
|
||||
}
|
||||
// Phase 114 (TODO L6): the SSE error frame's optional hint flows to
|
||||
@@ -1511,8 +1528,15 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None:
|
||||
moves BEFORE the block append — the append's height growth must
|
||||
not leak into the "pre" reading; the first-frame edge case — a
|
||||
fresh window is always pinned; the legacy block-level pin
|
||||
capture stays, no-op'ing on the divs): the full source must
|
||||
match the pin — the ``reask`` path, the turn-local resets, the
|
||||
capture stays, no-op'ing on the divs) and phase 164 (tasks 02+03)'s
|
||||
sanctioned changes (the pre-stream ``!res.ok`` detail read goes
|
||||
through the shared ``errorDetailText`` serializer — the live 422
|
||||
list-of-objects no longer stringifies to ``[object Object]``; the
|
||||
task-03 pre-stream throw carries ``bubbleText`` (FAILED_REQUEST_TEXT)
|
||||
+ the phase-114 ``hint``, and the catch forwards ``bubbleText:
|
||||
err?.bubbleText`` into the failed-turn funnel — locked A3): the
|
||||
full source must match the pin — the ``reask`` path, the turn-local
|
||||
resets, the
|
||||
SSE contract, the save points, and the sanctioned additions (the
|
||||
task-01 ``lastUserWrap`` assignment + the idle-settle mark
|
||||
pass, the task-03 aborted-settle mark pair, the phase-160
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Unit: the shared ``errorDetailText`` serializer (phase 164, task 02).
|
||||
|
||||
Behavior-pins the exported helper under node (the ``_run_node`` house
|
||||
pattern — the function is pure, so its extracted source runs standalone)
|
||||
across the full shape matrix, led by the EXACT live FastAPI 422 body of
|
||||
2026-10-07: a pydantic list-of-objects ``detail`` must serialize to the
|
||||
``msg`` verbatim — never ``"[object Object]"``, never the rejected
|
||||
``input`` text (the entries carry the full conversation — it must never
|
||||
reach the DOM). Source pins: the two chat call sites now read through
|
||||
the helper, the helper never ``JSON.stringify``-s anything, and the
|
||||
SEVEN existing guarded detail-readers (tuning.js, theme.js,
|
||||
document.js, git-sources.js, doc-edit.js, app.js's tuning form and its
|
||||
image-upload guard) stay byte-identical — no opportunistic refactor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
APP_JS = ASSETS / "app.js"
|
||||
CHAT_PY = Path(__file__).resolve().parents[2] / "app" / "api" / "chat.py"
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _asset(name: str) -> str:
|
||||
return (ASSETS / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The full source of ``export function name(…) {…}`` via
|
||||
balanced-brace counting (the test_frontend_* convention — the
|
||||
``export`` prefix still matches the ``function name(`` search)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in app.js"
|
||||
depth = 0
|
||||
for i in range(js.find("{", start), len(js)):
|
||||
if js[i] == "{":
|
||||
depth += 1
|
||||
elif js[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}()")
|
||||
|
||||
|
||||
#: Sentinel serialized to the JS literal ``undefined`` (json has none).
|
||||
class _Undef:
|
||||
pass
|
||||
|
||||
|
||||
UNDEFINED = _Undef()
|
||||
|
||||
|
||||
def _lit(v: object) -> str:
|
||||
if isinstance(v, _Undef):
|
||||
return "undefined"
|
||||
return json.dumps(v)
|
||||
|
||||
|
||||
def _serialize(cases: list[tuple[str, object, object]]) -> dict[str, str]:
|
||||
"""Run the REAL exported ``errorDetailText`` under node over
|
||||
``[(name, value, fallback), …]`` and return ``{name: result}``."""
|
||||
script = (
|
||||
_fn(_js(), "errorDetailText")
|
||||
+ "\nconst out = [];\n"
|
||||
+ "".join(
|
||||
f"out.push([{_lit(name)}, errorDetailText({_lit(value)}, {_lit(fallback)})]);\n"
|
||||
for name, value, fallback in cases
|
||||
)
|
||||
+ "for (const row of out) console.log(JSON.stringify(row));\n"
|
||||
)
|
||||
rows = (json.loads(line) for line in _run_node(script).splitlines() if line)
|
||||
return {row[0]: row[1] for row in rows}
|
||||
|
||||
|
||||
#: The EXACT live 422 body (phase 164 overview): the follow-up turn of
|
||||
#: a >32k-char answer, rejected by the old HistoryTurn.text cap. The
|
||||
#: `input` is the whole rejected conversation — here a marked stand-in
|
||||
#: that must NEVER appear in the serialized result.
|
||||
LIVE_422_DETAIL = [
|
||||
{
|
||||
"type": "string_too_long",
|
||||
"loc": ["body", "history", 5, "text"],
|
||||
"msg": "String should have at most 600000 characters",
|
||||
"input": f"CANARY-INPUT {'x' * 4000}",
|
||||
}
|
||||
]
|
||||
|
||||
FB = "FALLBACK-LINE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Behavior — the shape matrix under node (the exported, REAL function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_live_422_list_detail_serializes_the_msg_only() -> None:
|
||||
"""The EXACT live 422 body: the entry's ``msg`` verbatim, and the
|
||||
result carries NO ``input`` text and no ``"[object Object]"`` —
|
||||
the 2026-10-07 failure is unrepresentable (locked A2)."""
|
||||
got = _serialize([("live422", LIVE_422_DETAIL, FB)])
|
||||
assert got["live422"] == "String should have at most 600000 characters"
|
||||
assert "CANARY" not in got["live422"], "the rejected input must never serialize"
|
||||
assert "[object Object]" not in got["live422"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_array_entries_join_with_semicolon_space() -> None:
|
||||
"""Multi-entry arrays join their usable texts with ``"; "``:
|
||||
several object ``msg``s, and mixed object + plain-string entries
|
||||
(the generalization of the seven guarded copies)."""
|
||||
got = _serialize(
|
||||
[
|
||||
(
|
||||
"multi_obj",
|
||||
[{"msg": "first problem"}, {"msg": "second problem"}],
|
||||
FB,
|
||||
),
|
||||
("mixed", [{"msg": "obj msg"}, "plain string"], FB),
|
||||
("plain_only", ["a", "b"], FB),
|
||||
("number_entry", [42], FB),
|
||||
]
|
||||
)
|
||||
assert got["multi_obj"] == "first problem; second problem"
|
||||
assert got["mixed"] == "obj msg; plain string"
|
||||
assert got["plain_only"] == "a; b"
|
||||
assert got["number_entry"] == "42"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_empty_array_entries_drop_and_all_empty_falls_back() -> None:
|
||||
"""Entries with neither a usable ``msg`` nor a non-empty string
|
||||
drop (objects without msg, empty msgs, nullish, empty strings); an
|
||||
all-empty array — and the bare ``[]`` — fall back."""
|
||||
got = _serialize(
|
||||
[
|
||||
("drop_and_keep", [{"nomsg": 1}, {"msg": ""}, None, "", {"msg": "kept"}], FB),
|
||||
("all_empty", [{"nomsg": 1}, None, ""], FB),
|
||||
("bare_empty", [], FB),
|
||||
]
|
||||
)
|
||||
assert got["drop_and_keep"] == "kept"
|
||||
assert got["all_empty"] == FB
|
||||
assert got["bare_empty"] == FB
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_object_detail_msg_or_fallback() -> None:
|
||||
"""A plain object with a non-empty ``msg`` serializes it verbatim;
|
||||
without one — even carrying a huge rejected ``input`` — it falls
|
||||
back (never stringified)."""
|
||||
got = _serialize(
|
||||
[
|
||||
("obj_msg", {"type": "string_too_long", "msg": "only the msg"}, FB),
|
||||
("obj_nomsg", {"input": "CANARY " * 500}, FB),
|
||||
("obj_empty_msg", {"msg": ""}, FB),
|
||||
]
|
||||
)
|
||||
assert got["obj_msg"] == "only the msg"
|
||||
assert got["obj_nomsg"] == FB
|
||||
assert got["obj_empty_msg"] == FB
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_string_verbatim_incl_server_fixed_copy_otherwise_fallback() -> None:
|
||||
"""A non-empty string serializes VERBATIM — the pinned server-built
|
||||
frame copies ("Question too long…", "The chat model dropped the
|
||||
connection…") must reach the user byte-identical. ``""``, null,
|
||||
undefined, a number, and a boolean all fall back."""
|
||||
server_copy_1 = "Question too long — trim it and re-ask."
|
||||
server_copy_2 = "The chat model dropped the connection — try again?"
|
||||
# The server-side half of the pin: these ARE the ChatErrorEvent copies.
|
||||
chat_py = CHAT_PY.read_text(encoding="utf-8")
|
||||
assert f'detail="{server_copy_1}"' in chat_py
|
||||
assert f'detail="{server_copy_2}"' in chat_py
|
||||
got = _serialize(
|
||||
[
|
||||
("server_copy_1", server_copy_1, FB),
|
||||
("server_copy_2", server_copy_2, FB),
|
||||
("plain", "some string", FB),
|
||||
("empty", "", FB),
|
||||
("null", None, FB),
|
||||
("undefined", UNDEFINED, FB),
|
||||
("number", 42, FB),
|
||||
("boolean", True, FB),
|
||||
]
|
||||
)
|
||||
assert got["server_copy_1"] == server_copy_1
|
||||
assert got["server_copy_2"] == server_copy_2
|
||||
assert got["plain"] == "some string"
|
||||
for name in ("empty", "null", "undefined", "number", "boolean"):
|
||||
assert got[name] == FB, name
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_no_shape_yields_object_object() -> None:
|
||||
"""TOTALITY sweep: across every shape the helper can meet, no
|
||||
result is ever ``"[object Object]"`` (or contains it) — the string
|
||||
is unrepresentable at the serializer level."""
|
||||
shapes = [
|
||||
LIVE_422_DETAIL,
|
||||
[{"type": "x", "loc": [1], "msg": "m", "input": "i"}] * 3,
|
||||
[{"a": {"b": 1}}],
|
||||
{"deep": {"nested": [1, 2]}},
|
||||
[[{"msg": "nested"}]],
|
||||
"s",
|
||||
0,
|
||||
None,
|
||||
UNDEFINED,
|
||||
]
|
||||
got = _serialize([(f"s{i}", v, FB) for i, v in enumerate(shapes)])
|
||||
for name, res in got.items():
|
||||
assert isinstance(res, str) and res, name
|
||||
assert "[object Object]" not in res, name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source pins — the export, the two call sites, the untouched copies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_error_detail_text_is_exported_for_unit_pinning() -> None:
|
||||
"""The helper is an EXPORTED function (the TURN_TIMEOUT_MS /
|
||||
THINKING_NEAR_BOTTOM_PX export-for-unit-pin idiom), declared
|
||||
exactly once."""
|
||||
js = _js()
|
||||
assert re.search(r"^export function errorDetailText\(value, fallback\) \{", js, re.M), (
|
||||
"errorDetailText must be an exported top-level function"
|
||||
)
|
||||
assert js.count("function errorDetailText(") == 1
|
||||
|
||||
|
||||
def test_helper_never_stringifies_never_reads_input_loc_ctx() -> None:
|
||||
"""LOCKED A2 inside the helper: NO ``JSON.stringify`` anywhere in
|
||||
its source, and the words ``input``/``loc``/``ctx`` never appear —
|
||||
only ``msg`` and plain strings serialize (the docstring names the
|
||||
rule but the FUNCTION body is what this pins)."""
|
||||
fn = _fn(_js(), "errorDetailText")
|
||||
assert "JSON.stringify" not in fn
|
||||
for forbidden in ("input", "loc", "ctx"):
|
||||
assert re.search(rf"\b{forbidden}\b", fn) is None, (
|
||||
f"the helper must never touch `{forbidden}` (the 422 entries "
|
||||
"carry the full rejected conversation)"
|
||||
)
|
||||
# The docstring above the helper names the live failure + the rule.
|
||||
js = _js()
|
||||
doc = js[: js.find("function errorDetailText(")][-2500:]
|
||||
assert "2026-10-07" in doc and "[object Object]" in doc
|
||||
assert "input" in doc, "the docstring names the input-leak rule"
|
||||
|
||||
|
||||
def test_prestream_call_site_reads_through_the_helper() -> None:
|
||||
"""The pre-stream ``!res.ok || !res.body`` handler: the unguarded
|
||||
``if (body.detail) detail = body.detail`` is GONE, the assignment
|
||||
reads ``errorDetailText(body.detail, …)`` with the per-status
|
||||
string as the fallback, and the try-around-res.json + non-JSON
|
||||
catch keep their shape."""
|
||||
js = _js()
|
||||
assert "if (body.detail) detail = body.detail;" not in js
|
||||
site = js.index('if (!res.ok || !res.body) {')
|
||||
block_end = js.index("await readSSE(", site)
|
||||
block = js[site:block_end]
|
||||
assert "let detail = `Brain's API answered with HTTP ${res.status}.`;" in block
|
||||
assert "detail = errorDetailText(body.detail, detail);" in block, (
|
||||
"the per-status default is the serializer's fallback"
|
||||
)
|
||||
assert "} catch { /* non-JSON error body */ }" in block
|
||||
# Phase 164 task 03 decorates THIS error (bubbleText + hint) before
|
||||
# the throw — the thrown message stays the serialized detail.
|
||||
assert "const err = new Error(detail);" in block
|
||||
assert "throw err;" in block
|
||||
# The fallback is exactly the status line the bubble must show for
|
||||
# a non-JSON 500 (phase 164 E2E case b).
|
||||
assert "Brain's API answered with HTTP" in block
|
||||
|
||||
|
||||
def test_sse_error_frame_call_site_reads_through_the_helper() -> None:
|
||||
"""The SSE ``error``-frame throw: ``new Error(errorDetailText(ev.detail,
|
||||
"Something went wrong on my side."))`` — and the phase-114
|
||||
``err.hint`` line stays byte-identical right after it."""
|
||||
js = _js()
|
||||
assert (
|
||||
'const err = new Error(errorDetailText(ev.detail, "Something went wrong on my side."));'
|
||||
in js
|
||||
)
|
||||
site = js.index('const err = new Error(errorDetailText(ev.detail,')
|
||||
tail = js[site : site + 200]
|
||||
assert tail.splitlines()[1].strip() == "err.hint = ev.hint;", (
|
||||
"the err.hint channel is untouched"
|
||||
)
|
||||
assert 'new Error(ev.detail ||' not in js, "the old unguarded read is gone"
|
||||
|
||||
|
||||
#: One guarded 422-reader block per existing site (the seven copies,
|
||||
#: D2 "stay byte-identical — no opportunistic refactor"): the exact
|
||||
#: idiom lines, file → (array-msg line, string line).
|
||||
GUARDED_COPIES: dict[str, tuple[str, str]] = {
|
||||
"tuning.js": (
|
||||
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
|
||||
' if (typeof data.detail === "string" && data.detail) return data.detail;',
|
||||
),
|
||||
"theme.js": (
|
||||
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
|
||||
' if (typeof data.detail === "string" && data.detail) return data.detail;',
|
||||
),
|
||||
"document.js": (
|
||||
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
|
||||
' if (typeof data.detail === "string" && data.detail) return data.detail;',
|
||||
),
|
||||
"git-sources.js": (
|
||||
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
|
||||
' if (typeof data.detail === "string" && data.detail) return data.detail;',
|
||||
),
|
||||
"doc-edit.js": (
|
||||
" if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {",
|
||||
' if (typeof data.detail === "string" && data.detail) return data.detail;',
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(GUARDED_COPIES))
|
||||
def test_existing_guarded_readers_are_byte_identical(name: str) -> None:
|
||||
"""The five external guarded readers keep their EXACT lines
|
||||
(indented, verbatim) — the phase touches only the two unguarded
|
||||
chat paths."""
|
||||
src = _asset(name)
|
||||
array_line, string_line = GUARDED_COPIES[name]
|
||||
assert array_line in src, f"{name} lost its guarded array-msg line"
|
||||
assert string_line in src, f"{name} lost its guarded string line"
|
||||
|
||||
|
||||
def test_app_js_guarded_copies_are_untouched() -> None:
|
||||
"""app.js's own guarded copies — the tuning-form reader (the
|
||||
three-branch idiom) and the image-upload string guard — stay
|
||||
byte-identical, and the tuning form was NOT opportunistically
|
||||
rewritten onto the new helper (exactly one Array-guard block in
|
||||
app.js: the tuning form)."""
|
||||
js = _js()
|
||||
guard = "if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {"
|
||||
assert js.count(guard) == 1
|
||||
assert "detail = String(data.detail[0].msg);" in js
|
||||
assert "} else if (typeof data.detail === \"string\" && data.detail) {" in js
|
||||
assert 'if (typeof body?.detail === "string") detail = body.detail;' in js
|
||||
@@ -137,9 +137,10 @@ def test_failed_turn_text_is_a_distinct_constant() -> None:
|
||||
assert failed_text != m_empty.group(1), (
|
||||
"FAILED_TURN_TEXT must be DISTINCT from EMPTY_ANSWER_FALLBACK"
|
||||
)
|
||||
# The zero-frame branch uses the constant, not a copy of the
|
||||
# fallback.
|
||||
assert 'addMessage("brain", FAILED_TURN_TEXT);' in js
|
||||
# The zero-frame branch uses the constant as its DEFAULT (phase 164
|
||||
# D3: ``bubbleText || FAILED_TURN_TEXT`` — only the pre-stream HTTP
|
||||
# rejection overrides it), never a copy of the fallback.
|
||||
assert "bubbleText || FAILED_TURN_TEXT" in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -160,8 +161,12 @@ def test_finalize_failed_turn_persists_failed_in_both_shapes() -> None:
|
||||
# …the detail trimmed + capped at 500 before persistence (the
|
||||
# schema's ChatMessage.error bound is the backstop)…
|
||||
assert '(detail || "").trim().slice(0, 500)' in body
|
||||
# …the zero-frame shape creates the FAILED_TURN_TEXT bubble…
|
||||
assert 'addMessage("brain", FAILED_TURN_TEXT);' in body
|
||||
# …the zero-frame shape creates the failed bubble (phase 164 D3:
|
||||
# ``bubbleText || FAILED_TURN_TEXT`` — the SAME value feeds the
|
||||
# bubble AND the record: stored-what-you-saw)…
|
||||
assert "const fwrapText = bubbleText || FAILED_TURN_TEXT;" in body
|
||||
assert 'addMessage("brain", fwrapText);' in body
|
||||
assert "rememberBrainTurn(\n fwrapText," in body
|
||||
assert "appendFailedNote(fwrap, error);" in body
|
||||
# …and the partial shape settles every open run closed (phase 158,
|
||||
# D5 — the stop-finalize pattern) and adds the note.
|
||||
@@ -448,6 +453,158 @@ def test_retry_last_turn_is_byte_unchanged() -> None:
|
||||
assert _fn_source(_js(), "retryLastTurn") == PINNED_RETRY_LAST_TURN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 164 task 03 — the distinct PRE-STREAM failure line (locked A3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The owner-locked bubble copy (D3) — pinned VERBATIM, exactly once.
|
||||
FAILED_REQUEST_TEXT_COPY = (
|
||||
"I couldn't take that — my API rejected the request. Use Retry to ask again."
|
||||
)
|
||||
|
||||
#: The phase-114 banner hint the pre-stream throw carries (D3).
|
||||
PRESTREAM_HINT_COPY = (
|
||||
"The app answered — it rejected the request before the model ran. "
|
||||
"Nothing was lost but this send."
|
||||
)
|
||||
|
||||
|
||||
def _prestream_block(js: str) -> str:
|
||||
"""The ``if (!res.ok || !res.body) { … }`` pre-stream handler block."""
|
||||
site = js.index("if (!res.ok || !res.body) {")
|
||||
return js[site : js.index("await readSSE(", site)]
|
||||
|
||||
|
||||
def _catch_else_body(js: str) -> str:
|
||||
"""``runTurn``'s catch: the plain ``else`` branch (non-abort,
|
||||
non-stop — the failed-turn funnel site)."""
|
||||
stop_branch = js.index('} else if (stoppedByUser || err?.name === "AbortError") {')
|
||||
else_branch = js.index("} else {", stop_branch)
|
||||
return js[else_branch : js.index("} finally {", else_branch)]
|
||||
|
||||
|
||||
def test_failed_request_text_is_owner_locked_and_distinct() -> None:
|
||||
"""``FAILED_REQUEST_TEXT`` is its OWN string literal with the exact
|
||||
owner-locked copy (D3), declared exactly once, DISTINCT from both
|
||||
``FAILED_TURN_TEXT`` (the connection line — a lie for a 4xx/5xx the
|
||||
server answered deliberately) and ``EMPTY_ANSWER_FALLBACK``."""
|
||||
js = _js()
|
||||
m_req = re.search(r'const FAILED_REQUEST_TEXT\s*=\s*\n?\s*"([^"]*)"', js)
|
||||
assert m_req, "const FAILED_REQUEST_TEXT must be a string literal"
|
||||
assert m_req.group(1) == FAILED_REQUEST_TEXT_COPY, (
|
||||
"the pre-stream copy is owner-locked — verbatim or not at all"
|
||||
)
|
||||
assert js.count("const FAILED_REQUEST_TEXT") == 1
|
||||
m_turn = re.search(r'const FAILED_TURN_TEXT\s*=\s*\n?\s*"([^"]*)"', js)
|
||||
m_empty = re.search(r'const EMPTY_ANSWER_FALLBACK\s*=\s*\n?\s*"([^"]*)"', js)
|
||||
assert m_turn and m_empty
|
||||
assert m_req.group(1) != m_turn.group(1)
|
||||
assert m_req.group(1) != m_empty.group(1)
|
||||
# The docstring names the finding + the not-for-transport boundary.
|
||||
doc = js[: js.index("const FAILED_REQUEST_TEXT")][-1200:]
|
||||
assert "2026-10-07" in doc and "PRE-STREAM" in doc
|
||||
|
||||
|
||||
def test_prestream_throw_carries_bubble_text_and_hint() -> None:
|
||||
"""The pre-stream ``!res.ok`` throw decorates the Error with
|
||||
``bubbleText = FAILED_REQUEST_TEXT`` AND the phase-114 ``hint``
|
||||
before throwing — the thrown message stays the serialized detail
|
||||
(task 02), so the note + banner keep the real reason while the
|
||||
bubble reads the honest pre-stream line."""
|
||||
block = _prestream_block(_js())
|
||||
assert "const err = new Error(detail);" in block
|
||||
assert "err.bubbleText = FAILED_REQUEST_TEXT;" in block
|
||||
assert f'"{PRESTREAM_HINT_COPY}"' in block, (
|
||||
"the banner hint copy is locked (D3) — verbatim"
|
||||
)
|
||||
# Both decorations ride the SAME thrown Error (set before the throw).
|
||||
throw = block.index("throw err;")
|
||||
assert 0 < block.index("err.bubbleText = FAILED_REQUEST_TEXT;") < throw
|
||||
assert 0 < block.index("err.hint") < throw
|
||||
assert "throw new Error(detail);" not in block
|
||||
|
||||
|
||||
def test_run_turn_catch_forwards_bubble_text_to_the_funnel() -> None:
|
||||
"""``runTurn``'s catch forwards ``bubbleText: err?.bubbleText``
|
||||
into ``finalizeFailedTurn`` (the ONE option addition) — optional
|
||||
chaining so a transport ``TypeError`` (no key, not even a custom
|
||||
Error) reads ``undefined`` and the settle keeps ``FAILED_TURN_TEXT``.
|
||||
The detail/hint/banner sequencing after the call is untouched."""
|
||||
else_body = _catch_else_body(_js())
|
||||
assert "bubbleText: err?.bubbleText," in else_body
|
||||
funnel = else_body.index("finalizeFailedTurn(detail, {")
|
||||
bubble = else_body.index("bubbleText: err?.bubbleText,")
|
||||
banner = else_body.index("setUiState(UI_STATE.error, detail,")
|
||||
assert funnel < bubble < banner, (
|
||||
"the key rides the funnel call, before the error-state settle "
|
||||
"(the phase-120 ordering contract)"
|
||||
)
|
||||
|
||||
|
||||
def test_finalize_reads_bubble_text_only_in_the_zero_frame_branch() -> None:
|
||||
"""``finalizeFailedTurn`` destructures ``bubbleText`` and reads it
|
||||
ONLY in the zero-frame branch (the partial-stream settle ignores
|
||||
the key — a pre-stream throw has no ``wrap`` by construction). The
|
||||
zero-frame settle puts the SAME ``text`` value into the bubble AND
|
||||
the persisted record (stored-what-you-saw, phase-120 contract
|
||||
extended), and touches no ``innerHTML``."""
|
||||
js = _js()
|
||||
src = _fn_source(js, "finalizeFailedTurn")
|
||||
assert "{ acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }" in src
|
||||
body = _fn_body(js, "finalizeFailedTurn")
|
||||
zero = body.index("} else {")
|
||||
assert "bubbleText" not in body[:zero], (
|
||||
"the partial branch must never read bubbleText"
|
||||
)
|
||||
# Code-level (comment-stripped) reads of the key: exactly one.
|
||||
code = "\n".join(
|
||||
line
|
||||
for line in body.splitlines()
|
||||
if not line.strip().startswith(("//", "*", "/*"))
|
||||
)
|
||||
assert code.count("bubbleText") == 1
|
||||
zero_part = body[zero:]
|
||||
assert "const fwrapText = bubbleText || FAILED_TURN_TEXT;" in zero_part
|
||||
assert 'addMessage("brain", fwrapText);' in zero_part
|
||||
assert "rememberBrainTurn(\n fwrapText," in zero_part, (
|
||||
"the bubble text and the record text are the SAME value"
|
||||
)
|
||||
assert "innerHTML" not in zero_part, "the failed bubble is text, never HTML"
|
||||
|
||||
|
||||
def test_sse_error_frame_throw_sets_no_bubble_text() -> None:
|
||||
"""The SSE ``error``-frame throw (a model-side failure AFTER the
|
||||
stream opened) decorates ``err.hint`` only — NO ``bubbleText``: a
|
||||
zero-frame error-frame settle keeps ``FAILED_TURN_TEXT``
|
||||
byte-identically (locked A3)."""
|
||||
js = _js()
|
||||
site = js.index('} else if (ev.type === "error") {')
|
||||
frame = js[site : js.index("throw err;", site) + len("throw err;")]
|
||||
assert "const err = new Error(errorDetailText(ev.detail," in frame, (
|
||||
"the frame throw reads through the serializer (task 02)"
|
||||
)
|
||||
assert '"Something went wrong on my side."));' in frame
|
||||
assert "err.hint = ev.hint;" in frame
|
||||
assert "bubbleText" not in frame
|
||||
|
||||
|
||||
def test_hint_reaches_banner_opts_for_both_throw_kinds() -> None:
|
||||
"""The phase-114 hint channel reaches the banner the SAME way for
|
||||
both throw kinds: the catch passes ``{ hint: err.hint }`` into
|
||||
``setUiState(UI_STATE.error, …)`` — the pre-stream throw sets a
|
||||
literal hint, the SSE frame throw copies ``ev.hint`` (undefined →
|
||||
showErrorBanner's default). The stream-drop guard's banner call
|
||||
keeps its optionless shape."""
|
||||
js = _js()
|
||||
assert (
|
||||
"setUiState(UI_STATE.error, detail, err instanceof Error ? { hint: err.hint } : {});"
|
||||
in _catch_else_body(js)
|
||||
)
|
||||
guard = js.index("if (!sawDone && !aborted && (acc || thinkingAcc)) {")
|
||||
zero_frame = js.index("if (!aborted && !wrap) {", guard)
|
||||
assert "setUiState(UI_STATE.error, detail);" in js[guard:zero_frame]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS — the in-bubble error line (the .stopped-note family, error color)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -323,7 +323,7 @@ def test_continue_button_is_not_added_on_the_other_settle_paths() -> None:
|
||||
js,
|
||||
"function finalizeFailedTurn(\n"
|
||||
" detail,\n"
|
||||
" { acc, thinking, tools, segments, wrap, leavePartialIndex }\n"
|
||||
" { acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }\n"
|
||||
") {",
|
||||
)
|
||||
assert "appendContinueButton" not in funnel, (
|
||||
|
||||
+221
-3
@@ -7,6 +7,20 @@ past it raises a pydantic ``ValidationError`` naming the offending field
|
||||
Plus the regression pin: a realistic ``bor.chat.v1`` payload validates
|
||||
cleanly and round-trips ``model_dump()`` (the stored-shape contract —
|
||||
the caps added value bounds only, no key/shape change).
|
||||
|
||||
Phase 164 (task 01, locked A1) re-cuts the long-answer chain —
|
||||
``HistoryTurn.text``/``.thinking``, ``ChatMessage.text``/``.thinking``
|
||||
and the thinking-segment cap to 600 000 (a FULL-cap answer at
|
||||
``BOR_MAX_OUTPUT_TOKENS=131_072``, ≈ 524 300 chars), the history list
|
||||
to 500 and the saved-messages list to 500, and adds the ONE new bound:
|
||||
the ``SavedChatCreate``/``SavedChatUpdate`` total-character budget
|
||||
(8 000 000) that keeps the anonymous SEC-05 surface bounded now that a
|
||||
single message may hold a full-length answer. The chat-path caps are
|
||||
pinned here too (``HistoryTurn`` + ``ChatRequest.history``) — same
|
||||
boundaries as the saved-chat mirror, and the 422 ``detail`` SHAPE a cap
|
||||
rejection produces is pinned as the server-side half of the phase-164
|
||||
serializer contract (a LIST OF OBJECTS — the live ``[object Object]``
|
||||
report).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,11 +28,13 @@ import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas import (
|
||||
SAVED_CHAT_TOTAL_CHARS_BUDGET,
|
||||
ChatMessage,
|
||||
ChatRequest,
|
||||
ChatSegment,
|
||||
ChatThinkingSegment,
|
||||
ChatToolSegment,
|
||||
HistoryTurn,
|
||||
SavedChatCreate,
|
||||
SavedChatUpdate,
|
||||
SourceRef,
|
||||
@@ -27,7 +43,14 @@ from app.schemas import (
|
||||
|
||||
# --- boundary constants (phase 83, A1 — mirror sources in app/schemas.py) ---
|
||||
|
||||
TEXT_CAP = 32_000 # mirrors HistoryTurn.text / .thinking
|
||||
#: Phase 164 (locked A1): 32 000 → 600 000. ONE constant for the whole
|
||||
#: mirror — ``HistoryTurn.text``/``.thinking`` (the chat path),
|
||||
#: ``ChatMessage.text``/``.thinking`` and the ``segments`` thinking
|
||||
#: entry: the identical-boundary test IS the proof the phase-83 mirror
|
||||
#: invariant survived the raise (raise one side and these pins fail).
|
||||
#: 600 000 ≈ a full-cap answer at ``BOR_MAX_OUTPUT_TOKENS=131_072``
|
||||
#: (≈ 524 300 chars at ~4 chars/token) with margin.
|
||||
TEXT_CAP = 600_000
|
||||
SOURCES_CAP = 20
|
||||
SUGGESTIONS_CAP = 50
|
||||
CHIP_CAP = 200
|
||||
@@ -37,8 +60,15 @@ PATH_CAP = 1000 # documents.path String(1000)
|
||||
TITLE_CAP = 500 # documents.title String(500)
|
||||
NAME_CAP = 100 # ToolCall.name
|
||||
ARGUMENT_CAP = 2000 # ToolCall.argument
|
||||
MESSAGES_CAP = 200 # SavedChatCreate/Update.messages
|
||||
MESSAGES_CAP = 500 # SavedChatCreate/Update.messages (phase 164: was 200)
|
||||
HISTORY_CAP = 500 # ChatRequest.history (phase 164: was 100)
|
||||
QUESTION_CAP = 4_000 # ChatRequest.message — the cap the composer mirrors (phase 104)
|
||||
#: The phase-164 anonymous-surface bound, imported from the source (not
|
||||
#: restated) so the schema and the suite cannot drift apart.
|
||||
TOTAL_BUDGET = SAVED_CHAT_TOTAL_CHARS_BUDGET
|
||||
#: A realistic full-cap answer length (131 072 output tokens at ~4
|
||||
#: chars/token) — the payload the whole chain must now carry.
|
||||
FULL_CAP_ANSWER = 524_300
|
||||
|
||||
|
||||
def _source_ref() -> dict:
|
||||
@@ -207,6 +237,103 @@ def test_chat_request_message_one_over_cap_rejects() -> None:
|
||||
_failed_loc(exc.value, "message")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HistoryTurn + ChatRequest.history — the CHAT path of the long-answer
|
||||
# chain (phase 164, task 01, locked A1). The live 422 this phase fixes:
|
||||
# a >32k-char brain answer rode back as ``history`` on the NEXT turn and
|
||||
# rejected it, so the follow-up of any long answer died with
|
||||
# "[object Object]". Same boundaries as the saved-chat mirror above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_history_turn_text_at_cap_validates() -> None:
|
||||
"""600 000 chars — a full-cap answer (≈ 524 300 at
|
||||
``BOR_MAX_OUTPUT_TOKENS=131_072``) flows back as ``history``:
|
||||
the exact request shape the browser sends on the turn AFTER a long
|
||||
answer."""
|
||||
turn = HistoryTurn(who="brain", text="a" * TEXT_CAP)
|
||||
assert len(turn.text) == TEXT_CAP
|
||||
# And the realistic full-cap answer, not just the boundary value.
|
||||
assert len(HistoryTurn(who="brain", text="a" * FULL_CAP_ANSWER).text) == FULL_CAP_ANSWER
|
||||
|
||||
|
||||
def test_history_turn_text_one_over_cap_rejects() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
HistoryTurn(who="brain", text="a" * (TEXT_CAP + 1))
|
||||
_failed_loc(exc.value, "text")
|
||||
|
||||
|
||||
def test_history_turn_thinking_at_cap_validates() -> None:
|
||||
turn = HistoryTurn(who="brain", text="ok", thinking="t" * TEXT_CAP)
|
||||
assert len(turn.thinking or "") == TEXT_CAP
|
||||
|
||||
|
||||
def test_history_turn_thinking_one_over_cap_rejects() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
HistoryTurn(who="brain", text="ok", thinking="t" * (TEXT_CAP + 1))
|
||||
_failed_loc(exc.value, "thinking")
|
||||
|
||||
|
||||
def test_history_turn_caps_match_the_saved_chat_mirror() -> None:
|
||||
"""The phase-83 mirror invariant, verified by identical boundaries
|
||||
(not by comment): a payload at the cap validates on BOTH models and
|
||||
one past it fails on BOTH — ``HistoryTurn`` (the chat path) and
|
||||
``ChatMessage`` (the saved-chat path). Raise one side in
|
||||
``app/schemas.py`` and this pin fails."""
|
||||
for length, rejected in ((TEXT_CAP, False), (TEXT_CAP + 1, True)):
|
||||
payload = {"who": "brain", "text": "a" * length, "thinking": "t" * length}
|
||||
for model in (HistoryTurn, ChatMessage):
|
||||
if rejected:
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate(payload)
|
||||
else:
|
||||
model.model_validate(payload)
|
||||
|
||||
|
||||
def test_chat_request_history_at_cap_validates() -> None:
|
||||
"""500 turns pass — the client sends the WHOLE ``bor.chat.v1``
|
||||
record minus the current question, so the ceiling is a DoS sanity
|
||||
bound only (the config budgets trim for real, locked A4)."""
|
||||
req = ChatRequest.model_validate(
|
||||
{"message": "hi", "history": [{"who": "user", "text": f"q{i}"} for i in range(HISTORY_CAP)]}
|
||||
)
|
||||
assert len(req.history) == HISTORY_CAP
|
||||
|
||||
|
||||
def test_chat_request_history_one_over_cap_rejects() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ChatRequest.model_validate(
|
||||
{
|
||||
"message": "hi",
|
||||
"history": [
|
||||
{"who": "user", "text": f"q{i}"} for i in range(HISTORY_CAP + 1)
|
||||
],
|
||||
}
|
||||
)
|
||||
_failed_loc(exc.value, "history")
|
||||
|
||||
|
||||
def test_cap_rejection_detail_is_a_list_of_objects() -> None:
|
||||
"""The server-side half of the phase-164 contract (task 02's input):
|
||||
a cap rejection is the pydantic 422 ``detail`` — a LIST OF OBJECTS
|
||||
(``loc``/``msg``/``type``), never a plain string. This is exactly the
|
||||
shape the client turned into ``[object Object]``; the serializer must
|
||||
read ``msg`` and never the ``input`` (which carries the whole
|
||||
conversation)."""
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
HistoryTurn(who="brain", text="a" * (TEXT_CAP + 1))
|
||||
detail = exc.value.errors()
|
||||
assert isinstance(detail, list) and len(detail) == 1
|
||||
(entry,) = detail
|
||||
assert entry["loc"] == ("text",)
|
||||
assert entry["type"] == "string_too_long"
|
||||
# The message the UI shows verbatim in the failed-turn note.
|
||||
assert entry["msg"] == f"String should have at most {TEXT_CAP} characters"
|
||||
# The raw input DOES ride the error object server-side — which is why
|
||||
# the client serializer only ever reads ``msg``.
|
||||
assert entry["input"].startswith("aaaa")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SourceRef (documents column-length mirrors)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -322,6 +449,97 @@ def test_saved_chat_update_messages_one_over_cap_rejects() -> None:
|
||||
_failed_loc(exc.value, "messages")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The phase-164 total-character budget (locked A1) — the ONE new bound on
|
||||
# the anonymous saved-chat surface. The per-message caps went to 600 000,
|
||||
# so the LIST is bounded by total characters instead of by an item count
|
||||
# that a full-cap answer would blow through: 8 000 000 keeps the worst
|
||||
# case at the order the phase-83 SEC-05 audit accepted (200 × 64k
|
||||
# ≈ 12.8 MB) while fitting ~15 full-cap answers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _full_cap_messages(count: int, *, size: int = FULL_CAP_ANSWER) -> list[dict]:
|
||||
"""``count`` brain answers at the realistic full-cap length."""
|
||||
return [{"who": "brain", "text": "a" * size} for _ in range(count)]
|
||||
|
||||
|
||||
def test_saved_chat_within_total_budget_of_full_cap_answers_validates() -> None:
|
||||
"""~15 full-cap answers (15 × 524 300 = 7 864 500 chars, the biggest
|
||||
whole-count fit under 8 000 000) validate — the budget is a sanity
|
||||
bound, not a practical limit on a real conversation."""
|
||||
fits = TOTAL_BUDGET // FULL_CAP_ANSWER
|
||||
assert fits == 15
|
||||
payload = SavedChatCreate.model_validate({"messages": _full_cap_messages(fits)})
|
||||
assert len(payload.messages) == fits
|
||||
|
||||
|
||||
def test_saved_chat_over_total_budget_rejects_with_the_budget_message() -> None:
|
||||
"""One full-cap answer past the fit → ValidationError whose ``msg``
|
||||
NAMES the budget (the 422 the anonymous caller sees), on BOTH the
|
||||
create and the update model — the same validator on both bodies."""
|
||||
over = _full_cap_messages(TOTAL_BUDGET // FULL_CAP_ANSWER + 1)
|
||||
for model in (SavedChatCreate, SavedChatUpdate):
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
model.model_validate({"messages": over})
|
||||
(error,) = exc.value.errors()
|
||||
assert error["loc"] == () # a model-level validator, not a field
|
||||
assert "8,000,000-character budget" in error["msg"]
|
||||
|
||||
|
||||
def test_saved_chat_one_full_cap_answer_plus_normal_turns_validates() -> None:
|
||||
"""The realistic case the live report blocked: a saved chat carrying
|
||||
ONE 525k-char brain answer alongside ordinary turns."""
|
||||
payload = SavedChatCreate.model_validate(
|
||||
{
|
||||
"messages": [
|
||||
{"who": "user", "text": "Write me the whole runbook."},
|
||||
{"who": "brain", "text": "a" * 525_000},
|
||||
{"who": "user", "text": "And the rollback?"},
|
||||
{"who": "brain", "text": "Short rollback."},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(payload.messages) == 4
|
||||
|
||||
|
||||
def test_saved_chat_total_budget_counts_thinking() -> None:
|
||||
"""``thinking`` counts against the budget exactly like ``text`` (the
|
||||
history budget's own accounting, mirrored): 15 turns of 280 000 text
|
||||
+ 280 000 thinking = 8 400 000 REJECTS even though the text alone is
|
||||
only 4 200 000 — a text-only accounting would have waved it
|
||||
through."""
|
||||
turns = 15
|
||||
size = 280_000
|
||||
assert turns * size <= TOTAL_BUDGET < turns * (size * 2)
|
||||
heavy = [
|
||||
{"who": "brain", "text": "a" * size, "thinking": "t" * size}
|
||||
for _ in range(turns)
|
||||
]
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
SavedChatCreate.model_validate({"messages": heavy})
|
||||
assert "8,000,000-character budget" in exc.value.errors()[0]["msg"]
|
||||
# One turn fewer fits (14 × 560 000 = 7 840 000).
|
||||
assert len(SavedChatCreate.model_validate({"messages": heavy[:-1]}).messages) == 14
|
||||
|
||||
|
||||
def test_saved_chat_at_exactly_the_total_budget_validates() -> None:
|
||||
"""The boundary is INCLUSIVE: exactly 8 000 000 characters across the
|
||||
messages passes (13 at the 600 000 cap + one of 200 000), one
|
||||
character more rejects."""
|
||||
|
||||
def _conversation(last_size: int) -> list[dict]:
|
||||
return [{"who": "brain", "text": "a" * TEXT_CAP} for _ in range(13)] + [
|
||||
{"who": "brain", "text": "b" * last_size}
|
||||
]
|
||||
|
||||
at = SavedChatCreate.model_validate({"messages": _conversation(200_000)})
|
||||
assert sum(len(m.text) for m in at.messages) == TOTAL_BUDGET
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
SavedChatCreate.model_validate({"messages": _conversation(200_001)})
|
||||
assert "8,000,000-character budget" in exc.value.errors()[0]["msg"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON-shape regression: same KEYS as before, value bounds only
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -747,7 +965,7 @@ def test_segments_absent_by_default_and_omitted_in_dump() -> None:
|
||||
|
||||
def test_segments_thinking_entry_round_trips_and_caps() -> None:
|
||||
"""The thinking entry: ``min_length=1`` (the zero-char run the
|
||||
client never stores) + ``max_length=32_000`` (HistoryTurn's cap,
|
||||
client never stores) + ``max_length=600_000`` (HistoryTurn's cap,
|
||||
phase 83) — at the cap validates, one past raises naming the
|
||||
offender; a missing ``text`` too."""
|
||||
at_cap = ChatMessage.model_validate(
|
||||
|
||||
Reference in New Issue
Block a user