Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(streaming): normalize inline thinking extraction across live and persisted turns (#3599) # Conflicts: # api/streaming.py # static/messages.js # static/ui.js * fix(streaming): code-aware inline-thinking extraction + position-aware unclosed handling Codex deep-review caught two regressions in the leading-only -> full-scan rewrite (both silent data-mangling on the persist/reload path): 1. Code-span unawareness: the scanner only protected triple fences, so a literal <think> in an inline single-backtick code span or an indented (>=4-space/tab) code block got silently extracted into reasoning. Added _inline_thinking_indented_code_at + inline-backtick tracking (Python + the JS twin _thinkingIndentedCodeAt), so all three code contexts now keep thinking tags visible. 2. Unclosed-tag truncation: any unmatched open tag moved the trailing prose into reasoning. Now position-aware — a LEADING unclosed block (cut off mid-thought) is still reasoning (#3455 intent), but an unclosed tag AFTER visible content stays visible so literal typed tags don't truncate prose. Gated partial handling on the previously-unused options.streaming param (live streaming keeps 'still thinking' behavior; persist/reload does not). Updated 2 tests that pinned the buggy behavior + added 4 regression tests (inline-backtick, indented-code, mid-body-unclosed-visible, leading-unclosed- extracted). Updated the node driver harness to include the new helper. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * fix(streaming): recognize fenced code blocks indented 1-3 spaces Codex round-3: a fence indented 1-3 spaces is valid Markdown but the fence detector only matched at column 0, so a literal think tag inside such a fence (not 4+-space indented code either) was still extracted. Both detectors (_inline_thinking_fence_marker_at / _thinkingFenceMarkerAt) now walk back over up to 3 leading spaces to a line start. Added backtick + tilde indented-fence regression tests. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * fix(streaming): O(n) inline-thinking scan + merge separate reasoning on reload Round-4 Codex deep-review caught two real issues in my own fixes: 1. PERF (O(n^2)): the indented-code check (_inline_thinking_indented_code_at / _thinkingIndentedCodeAt) scanned to line boundaries at EVERY character index, plus the leading check sliced+stripped the whole prefix per unclosed tag. On long no-newline content this was quadratic (~8.4s @ 200k, called repeatedly on the streaming path). Replaced with incremental O(1)-per-iteration line state (_line_is_indented_code / _lineIsIndentedCode evaluated only at line starts) + a seen_nonspace flag. 200k now extracts in ~55-140ms. 2. RELOAD reasoning-drop: renderMessages() seeded the shared extractor with '' so a message with BOTH an inline <think> block AND a separate m.reasoning payload showed only the inline part — the separate payload was dropped because the !thinkingText worklog resolution was then skipped. Now seeds with the message's direct reasoning (m.reasoning_content||m.reasoning||...) so the two MERGE (deduped); separate-only reasoning is preserved without promoting it into visible prose. Python + JS twins kept line-for-line parity. Added merge + perf + reload regression tests; updated the reload structure test and the node driver harness for the renamed helper. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * fix(streaming): revert reload reasoning-seed; keep O(n) perf fix Codex round-4 finding #2 (seed renderMessages' inline extractor with m.reasoning so a separate payload merges) turned out to VIOLATE a deliberate architectural invariant pinned by test_issue2565 + test_sprint42: the reload content-extraction path must NOT touch m.reasoning/m.reasoning_content — reasoning metadata is owned exclusively by the Worklog Thinking Card path (_worklogReasoningTextFromMessage / _assistantReasoningPayloadText), never conflated with inline-content extraction (which would risk promoting provider reasoning into final-answer prose). Reverted the ui.js seed to the PR's original `thinkingText` arg. The inline+separate merge is still a genuine extractor capability (exercised by the live streaming path via liveReasoningText) and is covered by a unit test, just not invoked from the reload render path by design. The O(n) perf fix (finding #1) and the code-awareness + position-aware unclosed handling (rounds 1-3) are all retained. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * fix(streaming): only lstrip extracted content when a leading block was removed Codex round-5 catch: the extractor unconditionally lstripped the final content (.lstrip() / .replace(/^\s+/,'')) even when NO thinking block was extracted, so an assistant reply that legitimately starts with an indented code block or blank lines lost its leading whitespace on live display, reload, and persistence. This was a real regression vs master (master returned non-thinking content unchanged). Now track leading_removed (set only when a LEADING thinking block/prefix is actually extracted) and lstrip only in that case. Mid-body / no-thinking content keeps its exact leading whitespace. Python + JS twins kept in parity; added backend regression tests (indented-first preserved, leading-blank preserved, leading-think still strips). Co-authored-by: rodboev <rodboev@users.noreply.github.com> * fix(streaming): reconnect restore prefers raw inflight accumulator Codex round-6 CORE catch: on reconnect, the single-live-message restore used (_liveInflightAssistant.content || ''). Because the PR now splits a leading unclosed <think> into empty content, restoring from the split content dropped the open tag — so a later </think> token leaked into the visible reply and corrupted the live accumulator. Restore from (_fullInflightAssistant || _liveInflightAssistant.content || '') so the raw open tag survives reconnect and the accumulator stays correct. Added a reconnect-restore regression test. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * Release v0.51.335 — Release KY (normalize inline thinking extraction, #3633) Unify inline-thinking (<think>/<|channel>/<|turn|>) extraction across live, reload, and persisted turns (#3599/#3633, @rodboev). Deep-reviewed: Opus + 6 Codex rounds; maintainer fixes resolved every Codex finding — code-awareness (inline-backtick/indented/1-3-space fences keep literal tags visible), position-aware unclosed handling, O(n) line scanning (was O(n^2) on long content), conditional lstrip (preserve leading whitespace when no leading block removed), and a reconnect-restore CORE fix (raw accumulator preferred so an open <think> tag survives reconnect). Python + JS twins in parity. Full suite 8330, Opus SHIP-SAFE, Codex SAFE-TO-SHIP, ESLint/scope-undef/ruff clean. Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
170 lines
8.6 KiB
Python
170 lines
8.6 KiB
Python
"""Regression coverage for the three #3401 deep-review fixes (release stage-3401).
|
|
|
|
#3401 (live-to-final assistant reply redesign) is a large refactor of the highest-traffic
|
|
render/stream surface. Its full test suite was green and Opus passed it, but a Codex
|
|
diff-vs-master regression read + a live-browser drive surfaced three regressions the
|
|
rewritten tests no longer guarded:
|
|
|
|
FIX 1 — inline ``<think>…</think>answer`` reasoning vanished. ``_assistantReasoningPayloadText``
|
|
used ``$``-anchored regexes, so a leading think block followed by a visible answer
|
|
extracted NO reasoning and the Thinking card never rendered (master used the
|
|
non-anchored form). The matching display-stripper is non-anchored, so the extractor
|
|
must be too.
|
|
|
|
FIX 2 — reconnect/reload duplicated the live reply. ``_rememberRunJournalCursor`` advanced a
|
|
closure-local seq but never wrote ``INFLIGHT[activeSid].lastRunJournalSeq`` — the value
|
|
``persistInflightState`` saves and a reload reads back as the ``after_seq`` replay
|
|
floor. So a hard reload restored ``lastAssistantText`` then replayed the journal from
|
|
``after_seq=0`` on top of it.
|
|
|
|
FIX 3 — the shipped Neon skin silently stopped working. The PR deleted the
|
|
``:root[data-skin="neon"]`` CSS while leaving Neon registered in the picker, so users
|
|
could select it and get default styling.
|
|
|
|
These are static source-structure / registration assertions plus a general
|
|
"every registered skin has CSS" guard so a dropped-skin regression fails fast.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
|
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
|
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
|
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_body(src: str, name: str) -> str:
|
|
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
|
assert match, f"{name}() not found"
|
|
brace = src.find("{", match.end())
|
|
depth = 1
|
|
i = brace + 1
|
|
while i < len(src) and depth:
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
i += 1
|
|
return src[brace + 1:i - 1]
|
|
|
|
|
|
# ── FIX 1: inline <think> reasoning extraction must be non-anchored ──
|
|
|
|
def test_reasoning_payload_extractor_is_not_dollar_anchored():
|
|
"""A leading <think> block followed by visible answer text must still yield the
|
|
reasoning. A trailing `$` anchor dropped it whenever the turn also had an answer."""
|
|
body = _function_body(UI_JS, "_assistantReasoningPayloadText")
|
|
# The think/thought/turn extraction regexes must NOT end with `$` after the close tag.
|
|
assert "<\\/think>\\s*$/" not in body and "</think>\\s*$/" not in body, (
|
|
"the <think> extraction regex must not be $-anchored (drops reasoning when a "
|
|
"visible answer follows the think block) — #3401 inline-think regression"
|
|
)
|
|
assert "<channel\\|>\\s*$/" not in body, "channel-thought extraction must not be $-anchored"
|
|
assert "<turn\\|>\\s*$/" not in body, "turn-thinking extraction must not be $-anchored"
|
|
# And the non-anchored leading-block form must be present.
|
|
assert "<think>([\\s\\S]*?)<\\/think>\\s*/" in body, (
|
|
"extractor must match a LEADING <think> block (non-anchored), mirroring "
|
|
"_stripLeadingAssistantThinkingMarkup"
|
|
)
|
|
|
|
|
|
def test_extractor_and_stripper_anchoring_agree():
|
|
"""The reasoning extractor and the display-content stripper must use the same
|
|
(non-anchored, leading-block) matching, or one shows a card the other can't strip."""
|
|
strip = _function_body(UI_JS, "_stripLeadingAssistantThinkingMarkup")
|
|
extract = _function_body(UI_JS, "_assistantReasoningPayloadText")
|
|
# Neither should be $-anchored on the think close tag.
|
|
assert "</think>\\s*$/" not in strip and "<\\/think>\\s*$/" not in strip
|
|
assert "</think>\\s*$/" not in extract and "<\\/think>\\s*$/" not in extract
|
|
|
|
|
|
# ── FIX 2: reconnect cursor must persist into INFLIGHT ──
|
|
|
|
def test_run_journal_cursor_persisted_into_inflight():
|
|
"""_rememberRunJournalCursor must write the advanced seq onto INFLIGHT so a reload
|
|
replays from the correct after_seq floor (not 0 over restored live text)."""
|
|
body = _function_body(MESSAGES_JS, "_rememberRunJournalCursor")
|
|
assert "INFLIGHT[activeSid]" in body, (
|
|
"the cursor must be mirrored onto the persisted INFLIGHT entry (#3401 reconnect dup)"
|
|
)
|
|
assert "lastRunJournalSeq=seq" in body.replace(" ", ""), (
|
|
"INFLIGHT[activeSid].lastRunJournalSeq must be set to the advanced seq"
|
|
)
|
|
# And a persist must be scheduled so the value survives a reload.
|
|
assert "_throttledPersist" in body or "persistInflightState" in body, (
|
|
"advancing the cursor must schedule an INFLIGHT persist"
|
|
)
|
|
|
|
|
|
def test_persist_inflight_saves_run_journal_seq():
|
|
"""persistInflightState must still save lastRunJournalSeq (the value reload reads back)."""
|
|
body = _function_body(MESSAGES_JS, "persistInflightState")
|
|
assert "lastRunJournalSeq" in body
|
|
|
|
|
|
# ── FIX 3: every registered skin must have CSS (general guard) ──
|
|
|
|
def test_neon_skin_css_restored():
|
|
assert ':root[data-skin="neon"]' in CSS, "Neon skin CSS block must be present"
|
|
assert ':root.dark[data-skin="neon"]' in CSS, "Neon dark variant CSS must be present"
|
|
|
|
|
|
def test_every_registered_skin_has_css():
|
|
"""Any skin offered in the picker (_SKINS in boot.js) must have a CSS block, so a
|
|
refactor cannot silently drop a shipped skin's styling while leaving it selectable."""
|
|
# Extract skin values registered in boot.js _SKINS = [ {name:'X', value:'y'}, ... ]
|
|
skins_block = re.search(r"_SKINS\s*=\s*\[(.*?)\]", BOOT_JS, re.DOTALL)
|
|
assert skins_block, "_SKINS registration not found in boot.js"
|
|
values = set(re.findall(r"value\s*:\s*'([a-z0-9-]+)'", skins_block.group(1)))
|
|
# 'default'/'system' style entries have no data-skin CSS; only check non-default skins.
|
|
css_skins = set(re.findall(r'data-skin="([a-z0-9-]+)"', CSS))
|
|
missing = sorted(s for s in values if s and s not in css_skins and s not in {"default", "system", ""})
|
|
assert not missing, f"registered skins with no CSS block (silent breakage): {missing}"
|
|
|
|
|
|
# ── FIX 4: settled tool-worklog rebuild must run while busy too (switch-back) ──
|
|
|
|
def test_settled_worklog_rebuild_not_gated_on_idle_only():
|
|
"""The settled tool/worklog/thinking rebuild must also run when busy if there are
|
|
tool calls. Gating purely on `!S.busy` dropped every prior settled turn's worklog
|
|
when renderMessages re-ran during an active stream (switch-back to in-progress
|
|
session) — the same content-loss-on-switch class as #3668. (#3401 regression)"""
|
|
body = UI_JS
|
|
# The rebuild guard must include the `|| (S.toolCalls && S.toolCalls.length)` arm.
|
|
assert re.search(
|
|
r"if\(!S\.busy\s*\|\|\s*\(S\.toolCalls\s*&&\s*S\.toolCalls\.length\)\)\{",
|
|
body,
|
|
), (
|
|
"the settled worklog rebuild must run while busy when tool calls exist "
|
|
"(if(!S.busy || (S.toolCalls && S.toolCalls.length))), not gate purely on !S.busy"
|
|
)
|
|
# And the bare `if(!S.busy){` immediately before that rebuild's worklog-wipe must be gone.
|
|
assert "if(!S.busy){\n inner.querySelectorAll('.tool-worklog-group" not in body, (
|
|
"the worklog-rebuild block must not be gated on the bare !S.busy form"
|
|
)
|
|
|
|
|
|
|
|
def test_reconnect_restore_prefers_raw_inflight_accumulator():
|
|
"""#3633 Codex CORE catch: on reconnect, the single-live-message restore must
|
|
prefer the RAW inflight accumulator (_fullInflightAssistant = lastAssistantText)
|
|
over the SPLIT live message content. Since the PR now splits a leading unclosed
|
|
<think> into empty content, restoring from _liveInflightAssistant.content alone
|
|
would drop the open tag — so a later </think> token would leak into the visible
|
|
reply and corrupt the accumulator. The raw text keeps the open tag intact."""
|
|
body = MESSAGES_JS
|
|
assert "_fullInflightAssistant || _liveInflightAssistant.content || ''" in body, (
|
|
"single-live-message reconnect restore must be "
|
|
"(_fullInflightAssistant || _liveInflightAssistant.content || '') so the "
|
|
"raw open <think> tag survives reconnect"
|
|
)
|
|
# The buggy form (split content preferred first) must be gone.
|
|
assert "? (_liveInflightAssistant.content || '')\n" not in body, (
|
|
"reconnect restore must not prefer the split live content over the raw "
|
|
"inflight accumulator"
|
|
)
|