Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(streaming): make per-token inline-thinking extraction linear (#3633 follow-up) Codex post-merge perf catch on #3633: _parseStreamState() and syncInflightAssistantMessage() call _extractInlineThinkingFromContent on the FULL accumulated assistantText on EVERY streamed token. The #3633 rewrite made that a full char-by-char walk, so cost was O(n^2) over a stream — a Node harness measured ~88s (no-tag) / ~103s (leading <think> block) for 2000x100-char tokens, which would freeze the main thread on long reasoning-model responses. Two fixes (Python api/streaming.py + JS static/messages.js twin, line-for-line parity): 1. Fast path: if the text contains no complete thinking opener AND (when streaming) its tail is not a prefix of an opener, return unchanged without the char walk — two cheap substring scans. Handles the common no-tag case. 2. Bulk-skip plain trailing content: track the next complete opener via str.find/indexOf (_next_inline_thinking_opener / _nextThinkingOpener); once no opener remains ahead, append the remainder and stop instead of walking it (streaming still suppresses a trailing partial-opener prefix). Handles the leading-block-then-long-answer case. Result: ~88s/103s → ~0.5s/0.9s (Python), ~0.18s/0.21s (JS). All behavioral cases (persist + streaming, code-awareness, position-aware unclosed, leading whitespace) verified unchanged in both twins. Added a per-token streaming perf regression test and wired _nextThinkingOpener into the node driver harness. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * fix(streaming): perf bulk-skip must respect code context for partial-opener tails Codex catch on the perf fix: the no-complete-opener bulk-skip suppressed a trailing partial opener (e.g. '<thi') unconditionally during streaming, but a partial opener INSIDE inline-backtick / fenced / indented code must stay visible (master parity). Now, when streaming and the tail is a partial opener, fall through to the code-aware char walk (bounded — a partial tail is a transient single token) instead of bulk-skipping; only a PLAIN-text partial opener is suppressed as a forming block. Added _text_tail_is_partial_opener / _textTailIsPartialOpener (Python + JS parity) + regression tests for the inside-code vs plain partial-tail cases. Co-authored-by: rodboev <rodboev@users.noreply.github.com> * docs(changelog): v0.51.336 KZ — inline-thinking streaming perf fix --------- Co-authored-by: rodboev <rodboev@users.noreply.github.com> Co-authored-by: Hermes Agent <hermes-agent@nesquena-hermes.local>
194 lines
8.1 KiB
Python
194 lines
8.1 KiB
Python
from pathlib import Path
|
|
|
|
from api.streaming import (
|
|
_extract_inline_thinking_from_content,
|
|
_split_thinking_from_content,
|
|
)
|
|
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_body(src: str, signature: str) -> str:
|
|
start = src.index(signature)
|
|
brace = src.index("{", start)
|
|
depth = 0
|
|
for i in range(brace, len(src)):
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[start : i + 1]
|
|
raise AssertionError(f"function body not found: {signature}")
|
|
|
|
|
|
def test_split_clean_leading_think_block():
|
|
content, reasoning = _split_thinking_from_content("<think>plan</think>\nanswer")
|
|
|
|
assert content == "answer"
|
|
assert reasoning == "plan"
|
|
|
|
|
|
def test_split_extracts_non_leading_complete_block():
|
|
content, reasoning = _split_thinking_from_content("visible before <think>hidden</think> visible after")
|
|
|
|
assert "<think>" not in content
|
|
assert "visible before" in content
|
|
assert "visible after" in content
|
|
assert reasoning == "hidden"
|
|
|
|
|
|
def test_split_extracts_multiple_complete_blocks():
|
|
content, reasoning = _split_thinking_from_content("<think>one</think><think>two</think> final")
|
|
|
|
assert content == "final"
|
|
assert reasoning == "one\n\ntwo"
|
|
|
|
|
|
def test_split_keeps_fenced_code_literal_think_visible():
|
|
raw = "```html\n<think>literal</think>\n```\nanswer"
|
|
content, reasoning = _split_thinking_from_content(raw)
|
|
|
|
assert content == raw
|
|
assert reasoning == ""
|
|
|
|
|
|
def test_split_merges_existing_reasoning_without_duplicate():
|
|
content, reasoning = _split_thinking_from_content("<think>same</think>answer", "same")
|
|
|
|
assert content == "answer"
|
|
assert reasoning == "same"
|
|
|
|
|
|
def test_split_merges_existing_reasoning_with_new_inline_block():
|
|
content, reasoning = _split_thinking_from_content("<think>inline</think>answer", "separate")
|
|
|
|
assert content == "answer"
|
|
assert reasoning == "separate\n\ninline"
|
|
|
|
|
|
def test_reasoning_only_content_survives_reload_source_fields():
|
|
content, reasoning = _split_thinking_from_content("<think>only reasoning</think>")
|
|
|
|
assert content == ""
|
|
assert reasoning == "only reasoning"
|
|
|
|
|
|
def test_unclosed_inline_thinking_after_content_stays_visible_on_persist():
|
|
"""#3633 deep-review (Codex catch): on the PERSIST path an unclosed think tag
|
|
that appears AFTER visible content is almost always a literal typed tag, so
|
|
the prose after it must NOT be silently truncated into reasoning. A LEADING
|
|
unclosed block (cut off mid-thought) is still treated as reasoning."""
|
|
# Mid-body unclosed → stays fully visible, nothing moved to reasoning.
|
|
content, reasoning = _split_thinking_from_content("answer<think>still thinking")
|
|
assert content == "answer<think>still thinking"
|
|
assert reasoning == ""
|
|
|
|
# Leading unclosed → genuine cut-off thinking trace, moves to reasoning.
|
|
lead_content, lead_reasoning = _split_thinking_from_content("<think>still thinking")
|
|
assert lead_content == ""
|
|
assert lead_reasoning == "still thinking"
|
|
|
|
|
|
def test_messages_js_live_and_persist_paths_share_extractor():
|
|
stream_display = _function_body(MESSAGES_JS, "function _streamDisplay")
|
|
parse_state = _function_body(MESSAGES_JS, "function _parseStreamState")
|
|
split_persist = _function_body(MESSAGES_JS, "function _splitThinkFromContent")
|
|
|
|
assert "_extractInlineThinkingFromContent(_stripXmlToolCalls(assistantText), liveReasoningText, {streaming:true}).content" in stream_display
|
|
assert "return _extractInlineThinkingFromContent(_stripXmlToolCalls(assistantText), liveReasoningText, {streaming:true});" in parse_state
|
|
assert "return _extractInlineThinkingFromContent(rawContent, existingReasoning, {streaming:false});" in split_persist
|
|
assert "window._extractInlineThinkingFromContentForRender" in MESSAGES_JS
|
|
assert "_thinkingFenceMarkerAt" in MESSAGES_JS
|
|
|
|
|
|
def test_render_messages_uses_shared_extractor_on_reload():
|
|
render = _function_body(UI_JS, "function renderMessages")
|
|
|
|
assert "window._extractInlineThinkingFromContentForRender(content, thinkingText)" in render
|
|
assert "thinkingText=split.reasoning||thinkingText" in render
|
|
assert "content=split.content" in render
|
|
|
|
|
|
def test_inline_and_separate_reasoning_merge_not_drop():
|
|
"""#3633: the extractor MERGES inline + an explicitly-passed separate reasoning
|
|
payload (deduped) rather than dropping either. (The reload render path itself
|
|
deliberately does NOT seed m.reasoning into this extractor — that separation
|
|
is pinned by test_issue2565; the merge capability is exercised by the live
|
|
streaming path which passes liveReasoningText.)"""
|
|
content, reasoning = _split_thinking_from_content("<think>inline</think>answer", "separate")
|
|
assert content == "answer"
|
|
assert reasoning == "separate\n\ninline"
|
|
|
|
# Identical inline + separate dedupe to one.
|
|
content2, reasoning2 = _split_thinking_from_content("<think>same</think>answer", "same")
|
|
assert content2 == "answer"
|
|
assert reasoning2 == "same"
|
|
|
|
# Separate-only (no inline tag) is preserved and content is untouched
|
|
# (no promotion of reasoning into visible prose).
|
|
content3, reasoning3 = _split_thinking_from_content("plain answer", "separate")
|
|
assert content3 == "plain answer"
|
|
assert reasoning3 == "separate"
|
|
|
|
|
|
def test_extraction_is_linear_on_long_no_newline_content():
|
|
"""#3633 Codex perf catch: the indented-code / leading checks must not be
|
|
O(n^2). A 200k-char no-newline message must extract well under a second."""
|
|
import time
|
|
|
|
big = "x" * 200_000 + "answer"
|
|
start = time.time()
|
|
content, reasoning = _split_thinking_from_content(big)
|
|
elapsed = time.time() - start
|
|
assert content == big
|
|
assert reasoning == ""
|
|
assert elapsed < 1.0, f"extraction took {elapsed:.2f}s — likely quadratic"
|
|
|
|
|
|
def test_per_token_streaming_scan_is_not_quadratic():
|
|
"""#3633 Codex CORE perf catch: _parseStreamState / syncInflightAssistantMessage
|
|
call the extractor on the FULL accumulator on every streamed token. Simulate a
|
|
long stream (both no-tag and leading-thinking-block cases) and assert the
|
|
cumulative cost stays bounded — a per-token full walk over the growing buffer
|
|
was O(n^2) (~88s no-tag / ~103s with-tag for 2000x100-char tokens)."""
|
|
import time
|
|
|
|
def sim(n_tokens, tok_len, lead_tag):
|
|
acc = "<think>short reasoning</think>" if lead_tag else ""
|
|
start = time.time()
|
|
for _ in range(n_tokens):
|
|
acc += "x" * tok_len
|
|
# mimic the two per-token extractor calls (_streamDisplay + _parseStreamState)
|
|
_extract_inline_thinking_from_content(acc, "", streaming=True)
|
|
_extract_inline_thinking_from_content(acc, "", streaming=True)
|
|
return time.time() - start
|
|
|
|
no_tag = sim(2000, 100, False)
|
|
with_tag = sim(2000, 100, True)
|
|
assert no_tag < 3.0, f"no-tag per-token stream took {no_tag:.1f}s — quadratic"
|
|
assert with_tag < 3.0, f"with-tag per-token stream took {with_tag:.1f}s — quadratic"
|
|
|
|
|
|
def test_streaming_partial_opener_tail_respects_code_context():
|
|
"""#3633 perf-fix follow-up (Codex): the bulk-skip fast path must not suppress
|
|
a trailing partial opener that sits inside code — only a partial opener in
|
|
PLAIN text is a forming block. Mirrors master parity for inline-backtick,
|
|
fenced, and indented code; a plain partial tail is still suppressed."""
|
|
ext = _extract_inline_thinking_from_content
|
|
# Inside code → the partial opener tail stays visible.
|
|
assert ext("answer `<thi", "", streaming=True)[0] == "answer `<thi"
|
|
assert ext("```\n<thi", "", streaming=True)[0] == "```\n<thi"
|
|
assert ext(" <thi", "", streaming=True)[0] == " <thi"
|
|
# Plain text → the forming partial opener is suppressed from display.
|
|
assert ext("answer <thi", "", streaming=True)[0] == "answer "
|
|
|
|
|
|
def test_timeout_wrapper_remains_out_of_scope():
|
|
assert "Request timed out. Please try again." in WORKSPACE_JS
|
|
assert "AbortController" in WORKSPACE_JS
|