Release v0.51.268 — Release IJ (stage-b1 — low-risk perf + provider/clarify fixes) (#3671)
* perf(providers): O(1) codex cache merge membership checks (#3656) Co-authored-by: Pamnard <pamnard@users.noreply.github.com> * fix(models): add MiniMax-M3 to WebUI MiniMax fallback catalog test (#3627) Co-authored-by: Rod Boev <rod.boev@gmail.com> * fix(config): make DeepSeek reasoning-effort heuristic position-independent (#3650) Co-authored-by: happy5318 <happy5318@users.noreply.github.com> * fix(clarify): don't stash clarify draft while submission is in flight (#3651) Co-authored-by: carryzuo00 <carryzuo00@gmail.com> * perf(sessions): batch lineage report child fetch by parent id (#3659) Co-authored-by: Pamnard <pamnard@users.noreply.github.com> * perf(sessions): batch orphan sidecar state.db existence probes (#3657) Co-authored-by: Pamnard <pamnard@users.noreply.github.com> * test(streaming): pin DOM-INFLIGHT reattach invariant (#3572) Co-authored-by: Rod Boev <rod.boev@gmail.com> * docs(changelog): v0.51.268 — Release IJ (stage-b1) --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: Pamnard <pamnard@users.noreply.github.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: happy5318 <happy5318@users.noreply.github.com> Co-authored-by: carryzuo00 <carryzuo00@gmail.com>
This commit is contained in:
13 files changed
+499
-44
No files matched your search
@@ -3,6 +3,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.268] — 2026-06-05 — Release IJ (stage-b1 — low-risk perf + provider/clarify fixes)
|
||||
|
||||
### Fixed
|
||||
- **The DeepSeek reasoning-effort heuristic is now position-independent**, so the reasoning-effort selector correctly appears for V/R-series DeepSeek models even when a custom aggregator prepends a provider slug to the id (e.g. `@custom:name:DeepSeek-V4-Flash` → `my-provider-deepseek-v4-flash`). Non-reasoning variants (`deepseek-chat`, `deepseek-coder`) stay correctly excluded, and a provider slug that happens to start with `v`/`r` (e.g. `vertex`) no longer falsely triggers the version guard. (#3650, @happy5318)
|
||||
- **The clarify draft is no longer stashed while a clarify submission is in flight**, preventing a just-submitted answer from being re-stashed as a leftover draft when the prompt resolves. (#3651, @Carry00)
|
||||
|
||||
### Performance
|
||||
- **Codex live-model cache merge now uses O(1) set membership** instead of repeated list scans when reconciling the visible Codex model cache against live ids. (#3656, @pamnard)
|
||||
- **Session lineage child fetch is batched into a single query** (parents fetched with one `IN (...)` query and grouped in memory) instead of one SQLite query per parent segment. (#3659, @pamnard)
|
||||
- **Orphan CLI-sidecar existence probes are batched** into chunked `IN (...)` queries via a new `agent_session_rows_existing()` helper, replacing the per-row `agent_session_row_exists()` connection in the sidebar prune path. Degrades safely to "assume present" on any error so a transient failure never prunes a real session. (#3657, @pamnard)
|
||||
|
||||
### Internal
|
||||
- **Added a static-analysis contract test pinning the DOM-INFLIGHT reattach persistence invariant** (#3040): every `INFLIGHT[activeSid]` write in `send()` is paired with a `saveInflightState()` call so a reconnect/session-switch reseeds from durable state. (#3572, @rodboev)
|
||||
- **Added a regression test asserting `MiniMax-M3` is present in the MiniMax fallback model catalog.** (#3627, @rodboev)
|
||||
|
||||
## [v0.51.267] — 2026-06-04 — Release II (stage-r17 — TTS + CSRF forwarded-header security hardening)
|
||||
|
||||
### Security
|
||||
|
||||
+13
-4
@@ -595,7 +595,10 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
|
||||
segment_ids = {row['id'] for row in segments}
|
||||
child_rows: list[dict] = []
|
||||
for parent in segments:
|
||||
parent_ids = [row['id'] for row in segments]
|
||||
children_by_parent: dict[str, list[dict]] = {pid: [] for pid in parent_ids}
|
||||
if parent_ids:
|
||||
placeholders = ','.join('?' * len(parent_ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT s.id,
|
||||
@@ -607,13 +610,19 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
{ended_expr},
|
||||
{end_reason_expr}
|
||||
FROM sessions s
|
||||
WHERE s.parent_session_id = ?
|
||||
ORDER BY s.started_at DESC
|
||||
WHERE s.parent_session_id IN ({placeholders})
|
||||
""",
|
||||
(parent['id'],),
|
||||
parent_ids,
|
||||
)
|
||||
for child_row in cur.fetchall():
|
||||
child = dict(child_row)
|
||||
parent_id = child.get('parent_session_id')
|
||||
if parent_id in children_by_parent:
|
||||
children_by_parent[parent_id].append(child)
|
||||
for parent in segments:
|
||||
parent_children = children_by_parent.get(parent['id'], [])
|
||||
parent_children.sort(key=lambda row: row.get('started_at') or 0, reverse=True)
|
||||
for child in parent_children:
|
||||
if child['id'] in segment_ids:
|
||||
continue
|
||||
if _is_continuation_session(parent, child):
|
||||
|
||||
+11
-4
@@ -2246,10 +2246,17 @@ def _candidate_supports_reasoning(candidate: str) -> bool:
|
||||
return True
|
||||
if "step" in token_set or normalized.startswith("step"):
|
||||
return True
|
||||
if normalized.startswith(("deepseek-v", "deepseek-r")):
|
||||
return True
|
||||
if len(tokens) >= 2 and tokens[0] == "deepseek" and tokens[1].startswith(("v", "r")):
|
||||
return True
|
||||
if "deepseek" in token_set:
|
||||
# Check the token immediately after "deepseek" for a V-series or R-series
|
||||
# version marker (e.g. "v4", "r1"). This is position-independent so it
|
||||
# handles bare "deepseek-v4-flash" and @custom:name:DeepSeek-V4-Flash
|
||||
# (→ "my-provider-deepseek-v4-flash") equally, while correctly excluding
|
||||
# non-reasoning models like deepseek-chat and deepseek-coder.
|
||||
# Using tokens.index() ensures the provider slug (e.g. "vertex" in
|
||||
# "vertex-deepseek-chat") cannot falsely trigger the version guard.
|
||||
idx = tokens.index("deepseek")
|
||||
if idx + 1 < len(tokens) and tokens[idx + 1].startswith(("v", "r")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+66
-25
@@ -2723,6 +2723,71 @@ def _active_state_db_path() -> Path:
|
||||
return hermes_home / 'state.db'
|
||||
|
||||
|
||||
def _agent_state_db_path(*, profile=None) -> Path | None:
|
||||
"""Return agent ``state.db`` for *profile*, or ``None`` when unavailable."""
|
||||
if isinstance(profile, str) and profile:
|
||||
db_path = _get_profile_home(profile) / 'state.db'
|
||||
if not db_path.exists():
|
||||
db_path = _active_state_db_path()
|
||||
else:
|
||||
db_path = _active_state_db_path()
|
||||
if not db_path.exists():
|
||||
return None
|
||||
return db_path
|
||||
|
||||
|
||||
def agent_session_rows_existing(
|
||||
session_ids: list[str] | set[str] | frozenset[str],
|
||||
*,
|
||||
profile=None,
|
||||
) -> frozenset[str]:
|
||||
"""Return session ids confirmed present in the agent ``sessions`` table.
|
||||
|
||||
Used by the sidebar orphan-prune path (#3238) to batch existence probes
|
||||
instead of opening one SQLite connection per candidate row.
|
||||
|
||||
Degrades safely to ``frozenset(wanted)`` (assume all present) on any error,
|
||||
when the DB is missing, or when the ``sessions`` table is absent — matching
|
||||
``agent_session_row_exists()`` so a transient failure never causes pruning.
|
||||
"""
|
||||
wanted = {str(sid).strip() for sid in (session_ids or []) if str(sid or "").strip()}
|
||||
if not wanted:
|
||||
return frozenset()
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return frozenset(wanted)
|
||||
db_path = _agent_state_db_path(profile=profile)
|
||||
if db_path is None:
|
||||
return frozenset(wanted)
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
cols = {str(row[1]) for row in cur.fetchall()}
|
||||
if 'id' not in cols:
|
||||
return frozenset(wanted)
|
||||
existing: set[str] = set()
|
||||
ids = list(wanted)
|
||||
chunk_size = 500
|
||||
for i in range(0, len(ids), chunk_size):
|
||||
chunk = ids[i:i + chunk_size]
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cur.execute(
|
||||
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
|
||||
chunk,
|
||||
)
|
||||
existing.update(str(row[0]).strip() for row in cur.fetchall())
|
||||
return frozenset(existing)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"agent_session_rows_existing probe failed for %d ids",
|
||||
len(wanted),
|
||||
exc_info=True,
|
||||
)
|
||||
return frozenset(wanted)
|
||||
|
||||
|
||||
def agent_session_row_exists(session_id: str, *, profile=None) -> bool:
|
||||
"""Return True if ``session_id`` still has a backing row in the agent state.db.
|
||||
|
||||
@@ -2739,31 +2804,7 @@ def agent_session_row_exists(session_id: str, *, profile=None) -> bool:
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return False
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return True
|
||||
if isinstance(profile, str) and profile:
|
||||
db_path = _get_profile_home(profile) / 'state.db'
|
||||
if not db_path.exists():
|
||||
db_path = _active_state_db_path()
|
||||
else:
|
||||
db_path = _active_state_db_path()
|
||||
if not db_path.exists():
|
||||
# No agent DB at all on this instance — can't claim the row is gone.
|
||||
return True
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
cols = {str(row[1]) for row in cur.fetchall()}
|
||||
if 'id' not in cols:
|
||||
return True
|
||||
cur.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (sid,))
|
||||
return cur.fetchone() is not None
|
||||
except Exception:
|
||||
logger.debug("agent_session_row_exists probe failed for %s", sid, exc_info=True)
|
||||
return True
|
||||
return sid in agent_session_rows_existing([sid], profile=profile)
|
||||
|
||||
|
||||
def _sidebar_title_is_generic_webui(title: str | None) -> bool:
|
||||
|
||||
+3
-1
@@ -1866,8 +1866,10 @@ def get_providers() -> dict[str, Any]:
|
||||
# follow-up to v0.51.19 #1812.)
|
||||
if pid == "openai-codex":
|
||||
live_ids = _read_live_provider_model_ids("openai-codex")
|
||||
live_id_set = set(live_ids)
|
||||
for mid in _read_visible_codex_cache_model_ids():
|
||||
if mid not in live_ids:
|
||||
if mid not in live_id_set:
|
||||
live_id_set.add(mid)
|
||||
live_ids.append(mid)
|
||||
live_models = _models_from_live_provider_ids(pid, live_ids)
|
||||
if live_models:
|
||||
|
||||
+40
-10
@@ -22,6 +22,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from contextlib import closing
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
@@ -2900,7 +2901,7 @@ from api.models import (
|
||||
_is_empty_partial_activity_message,
|
||||
_hide_from_default_sidebar,
|
||||
prune_session_from_index,
|
||||
agent_session_row_exists,
|
||||
agent_session_rows_existing,
|
||||
ensure_cron_project,
|
||||
is_cron_session,
|
||||
is_safe_session_id,
|
||||
@@ -5422,12 +5423,13 @@ def handle_get(handler, parsed) -> bool:
|
||||
# the sidecar is never pruned and the stale row lingers in the
|
||||
# sidebar forever (there is no WebUI delete affordance for it).
|
||||
# Drop rows whose backing agent row is genuinely gone. We probe
|
||||
# state.db directly (agent_session_row_exists) rather than trust
|
||||
# state.db directly (agent_session_rows_existing) rather than trust
|
||||
# cli_by_id absence, because get_cli_sessions() caps at
|
||||
# CLI_VISIBLE_SESSION_LIMIT (20) — an existing session can fall
|
||||
# out of that window and look deleted. Native WebUI sessions
|
||||
# (source == "webui") that merely have a CLI ancestor are never
|
||||
# pruned.
|
||||
_orphan_probe_rows = []
|
||||
_kept_after_orphan_prune = []
|
||||
for s in webui_sessions:
|
||||
_sid = s.get("session_id")
|
||||
@@ -5436,15 +5438,43 @@ def handle_get(handler, parsed) -> bool:
|
||||
and is_cli_session_row(s)
|
||||
and not _session_source_is_webui(s)
|
||||
and _sid not in cli_by_id
|
||||
and not agent_session_row_exists(_sid, profile=s.get("profile"))
|
||||
):
|
||||
try:
|
||||
prune_session_from_index(_sid)
|
||||
except Exception:
|
||||
logger.debug("Failed to prune orphaned CLI sidecar %s", _sid, exc_info=True)
|
||||
diag.stage("prune_orphaned_cli_sidecar")
|
||||
continue
|
||||
_kept_after_orphan_prune.append(s)
|
||||
_orphan_probe_rows.append(s)
|
||||
else:
|
||||
_kept_after_orphan_prune.append(s)
|
||||
if _orphan_probe_rows:
|
||||
rows_by_profile: dict[object, list[dict]] = defaultdict(list)
|
||||
for row in _orphan_probe_rows:
|
||||
rows_by_profile[row.get("profile")].append(row)
|
||||
missing_orphan_ids: set[str] = set()
|
||||
for profile_key, rows in rows_by_profile.items():
|
||||
probe_ids = [
|
||||
str(row.get("session_id")).strip()
|
||||
for row in rows
|
||||
if str(row.get("session_id") or "").strip()
|
||||
]
|
||||
existing = agent_session_rows_existing(
|
||||
probe_ids,
|
||||
profile=profile_key if isinstance(profile_key, str) and profile_key else None,
|
||||
)
|
||||
for row in rows:
|
||||
sid = str(row.get("session_id") or "").strip()
|
||||
if sid and sid not in existing:
|
||||
missing_orphan_ids.add(sid)
|
||||
for s in _orphan_probe_rows:
|
||||
_sid = str(s.get("session_id") or "").strip()
|
||||
if _sid in missing_orphan_ids:
|
||||
try:
|
||||
prune_session_from_index(_sid)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to prune orphaned CLI sidecar %s",
|
||||
_sid,
|
||||
exc_info=True,
|
||||
)
|
||||
diag.stage("prune_orphaned_cli_sidecar")
|
||||
continue
|
||||
_kept_after_orphan_prune.append(s)
|
||||
webui_sessions = _kept_after_orphan_prune
|
||||
for s in webui_sessions:
|
||||
meta = cli_by_id.get(s.get("session_id"))
|
||||
|
||||
@@ -3284,6 +3284,8 @@ function _startClarifyCountdown(pending) {
|
||||
|
||||
function _stashClarifyDraft(reason) {
|
||||
if (reason !== "expired" && reason !== "terminal") return false;
|
||||
const submit = $("clarifySubmit");
|
||||
if (submit && submit.classList.contains("loading")) return false;
|
||||
const input = $("clarifyInput");
|
||||
const draft = String((input && input.value) || "").trim();
|
||||
if (!draft) return false;
|
||||
|
||||
@@ -207,3 +207,44 @@ def test_unsupported_model_families_and_versions():
|
||||
)
|
||||
|
||||
|
||||
# ── position-independent DeepSeek version detection (#3650) ───────────────────
|
||||
#
|
||||
# The DeepSeek V/R-series check keys off the token immediately AFTER "deepseek"
|
||||
# rather than requiring "deepseek" to lead the string. This keeps detection
|
||||
# working when a provider slug is prepended (e.g. a custom aggregator rewriting
|
||||
# @custom:name:DeepSeek-V4-Flash → "my-provider-deepseek-v4-flash"), while a
|
||||
# provider slug that happens to start with "v"/"r" (e.g. "vertex") must NOT by
|
||||
# itself satisfy the version guard.
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
[
|
||||
"vertex-deepseek-v4-flash",
|
||||
"my-provider-deepseek-r1",
|
||||
"newapi-deepseek-v5",
|
||||
],
|
||||
)
|
||||
def test_deepseek_version_detected_after_provider_slug(model_id):
|
||||
efforts = cfg.resolve_model_reasoning_efforts(model_id, provider_id="custom:newapi")
|
||||
assert set(efforts) >= {"low", "medium", "high"}, (
|
||||
f"{model_id}: DeepSeek V/R-series marker after a provider slug should "
|
||||
"expose reasoning efforts (position-independent detection, #3650)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_id",
|
||||
[
|
||||
"deepseek-chat",
|
||||
"deepseek-coder",
|
||||
"vertex-deepseek-chat",
|
||||
],
|
||||
)
|
||||
def test_deepseek_non_reasoning_variants_excluded(model_id):
|
||||
efforts = cfg.resolve_model_reasoning_efforts(model_id, provider_id="custom:newapi")
|
||||
assert efforts == [], (
|
||||
f"{model_id}: non-reasoning DeepSeek variant must NOT resolve reasoning "
|
||||
"support, and a 'v'/'r' provider slug must not falsely trigger it (#3650)"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Static-analysis contract tests for the DOM-INFLIGHT reattach sync invariant (#3040).
|
||||
|
||||
Every site that writes to INFLIGHT[] must persist the change so that a reconnect
|
||||
or session-switch can seed the new closure from durable state rather than a stale
|
||||
DOM snapshot. These tests pin the structural invariants to catch regressions that
|
||||
remove a persistence call or add a new INFLIGHT write without pairing it with one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
MESSAGES_JS = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
_LINES = MESSAGES_JS.splitlines()
|
||||
|
||||
|
||||
def _line_window(line_no: int, radius: int = 20) -> str:
|
||||
"""Return up to `radius` lines on either side of `line_no` (1-based) as one string."""
|
||||
start = max(0, line_no - 1 - radius)
|
||||
end = min(len(_LINES), line_no - 1 + radius + 1)
|
||||
return "\n".join(_LINES[start:end])
|
||||
|
||||
|
||||
def _inflight_write_line_numbers() -> list[int]:
|
||||
"""Return 1-based line numbers for every INFLIGHT[activeSid] assignment site."""
|
||||
results = []
|
||||
for i, line in enumerate(_LINES, start=1):
|
||||
if re.search(r"INFLIGHT\[activeSid\]\s*=\s*\{", line) or re.search(
|
||||
r"INFLIGHT\[activeSid\]\s*\|\|\s*\(INFLIGHT\[activeSid\]\s*=\s*\{", line
|
||||
):
|
||||
results.append(i)
|
||||
return results
|
||||
|
||||
|
||||
def test_inflight_write_sites_exist():
|
||||
"""Sanity-check: messages.js must contain at least 3 INFLIGHT[activeSid] write sites."""
|
||||
sites = _inflight_write_line_numbers()
|
||||
assert len(sites) >= 3, (
|
||||
f"Expected at least 3 INFLIGHT[activeSid] assignment sites, found {len(sites)}. "
|
||||
"If send() was refactored, update the expected count."
|
||||
)
|
||||
|
||||
|
||||
def _extract_send_body() -> str:
|
||||
"""Extract the body of the send() function up to attachLiveStream() as one string."""
|
||||
# send() starts with `async function send(` and the INFLIGHT writes in the
|
||||
# optimistic path and catch block both precede the attachLiveStream call.
|
||||
send_start = MESSAGES_JS.find("async function send(")
|
||||
assert send_start != -1, "async function send() not found in messages.js."
|
||||
attach_call = MESSAGES_JS.find("attachLiveStream(activeSid, streamId", send_start)
|
||||
assert attach_call != -1, "attachLiveStream call not found inside send()."
|
||||
return MESSAGES_JS[send_start:attach_call]
|
||||
|
||||
|
||||
def test_send_inflight_writes_are_paired_with_saveInflightState():
|
||||
"""The INFLIGHT[activeSid] assignments in send() must be covered by saveInflightState()
|
||||
calls within the same send() scope.
|
||||
|
||||
send() makes the optimistic UI write before calling /api/chat/start so that a tab
|
||||
reload during the network round-trip can restore the user's message. If the write
|
||||
is not persisted, the only recovery window is lost.
|
||||
|
||||
There are 3 write sites but only 2 saveInflightState calls because the catch-block
|
||||
fallback write (line ~530) shares its persistence call with the post-start path:
|
||||
if /api/chat/start succeeds, the post-start block's saveInflightState covers it;
|
||||
if /api/chat/start fails, the error handler deletes INFLIGHT[activeSid] anyway.
|
||||
A minimum of 2 saveInflightState calls pins this intentional design.
|
||||
"""
|
||||
send_body = _extract_send_body()
|
||||
|
||||
# Count how many INFLIGHT assignments appear in send() before attachLiveStream.
|
||||
write_count = len(re.findall(r"INFLIGHT\[activeSid\]\s*=\s*\{", send_body))
|
||||
assert write_count >= 2, (
|
||||
f"Expected at least 2 INFLIGHT[activeSid] write sites in send(), found {write_count}. "
|
||||
"The optimistic write and the catch-block fallback should both be present."
|
||||
)
|
||||
|
||||
# There must be at least 2 saveInflightState calls covering the write paths.
|
||||
# (The catch-block fallback write shares the post-start persistence call.)
|
||||
persist_count = send_body.count("saveInflightState(")
|
||||
assert persist_count >= 2, (
|
||||
f"send() has only {persist_count} saveInflightState() call(s) before attachLiveStream "
|
||||
"(expected at least 2: one before /api/chat/start for optimistic reload recovery, "
|
||||
"one after start for stream-id persistence). "
|
||||
"Removing either leaves a reload window with unrecoverable INFLIGHT state."
|
||||
)
|
||||
|
||||
|
||||
def test_syncInflightAssistantMessage_calls_throttled_persist():
|
||||
"""syncInflightAssistantMessage() must call _throttledPersist() to flush the live
|
||||
assistant text into persistent INFLIGHT state on every token and interim_assistant event."""
|
||||
fn_start = MESSAGES_JS.find("function syncInflightAssistantMessage(){")
|
||||
assert fn_start != -1, "syncInflightAssistantMessage() not found in messages.js."
|
||||
fn_body_start = MESSAGES_JS.find("{", fn_start)
|
||||
# Walk braces to find the closing brace of the function body.
|
||||
depth = 1
|
||||
i = fn_body_start + 1
|
||||
while i < len(MESSAGES_JS) and depth:
|
||||
if MESSAGES_JS[i] == "{":
|
||||
depth += 1
|
||||
elif MESSAGES_JS[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
fn_body = MESSAGES_JS[fn_body_start:i]
|
||||
assert "_throttledPersist()" in fn_body, (
|
||||
"syncInflightAssistantMessage() must call _throttledPersist() so each "
|
||||
"live-text update is scheduled for persistence. Without this, a reconnect "
|
||||
"seeds from the last tool-event snapshot and loses all interim assistant text."
|
||||
)
|
||||
|
||||
|
||||
def test_interim_assistant_calls_syncInflightAssistantMessage_after_accumulating_text():
|
||||
"""The interim_assistant event handler must call syncInflightAssistantMessage()
|
||||
after it appends to assistantText and visibleInterimSnippets.
|
||||
|
||||
Without this call, the INFLIGHT assistant message is not updated before a
|
||||
potential reconnect, causing already-streamed content to be lost.
|
||||
"""
|
||||
handler_start = MESSAGES_JS.find("source.addEventListener('interim_assistant',")
|
||||
assert handler_start != -1, "interim_assistant event listener not found in messages.js."
|
||||
# Extract the handler body up to the matching closing brace.
|
||||
brace_pos = MESSAGES_JS.find("{", handler_start)
|
||||
depth = 1
|
||||
i = brace_pos + 1
|
||||
while i < len(MESSAGES_JS) and depth:
|
||||
if MESSAGES_JS[i] == "{":
|
||||
depth += 1
|
||||
elif MESSAGES_JS[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
handler_body = MESSAGES_JS[brace_pos:i]
|
||||
|
||||
assert "assistantText" in handler_body, (
|
||||
"interim_assistant handler does not reference assistantText — handler may have been refactored."
|
||||
)
|
||||
assert "visibleInterimSnippets" in handler_body, (
|
||||
"interim_assistant handler does not reference visibleInterimSnippets — "
|
||||
"interim snippet tracking was removed."
|
||||
)
|
||||
# syncInflightAssistantMessage() must appear AFTER the accumulation lines.
|
||||
accum_pos = handler_body.find("visibleInterimSnippets.push(")
|
||||
sync_pos = handler_body.find("syncInflightAssistantMessage()")
|
||||
assert sync_pos != -1, (
|
||||
"interim_assistant handler does not call syncInflightAssistantMessage(). "
|
||||
"Without this, INFLIGHT is not updated with interim text and a reconnect loses progress."
|
||||
)
|
||||
assert accum_pos != -1 and sync_pos > accum_pos, (
|
||||
"syncInflightAssistantMessage() must appear AFTER visibleInterimSnippets.push() "
|
||||
"so that the persisted snapshot includes the freshly accumulated snippet."
|
||||
)
|
||||
|
||||
|
||||
def test_attachLiveStream_reconnect_seeds_from_INFLIGHT_not_querySelector():
|
||||
"""On reconnect, attachLiveStream() must seed assistantText from INFLIGHT state,
|
||||
not from a live DOM query (querySelector).
|
||||
|
||||
Seeding from the DOM is fragile: an off-screen or BFCache-restored session may
|
||||
have no rendered content, so the closure would start empty and duplicate tokens
|
||||
would overwrite the already-displayed response.
|
||||
"""
|
||||
fn_start = MESSAGES_JS.find("function attachLiveStream(")
|
||||
assert fn_start != -1, "attachLiveStream() not found in messages.js."
|
||||
# Capture up to 60 lines after the function signature for the seed section.
|
||||
fn_head_end = fn_start
|
||||
for _ in range(60):
|
||||
fn_head_end = MESSAGES_JS.find("\n", fn_head_end + 1)
|
||||
if fn_head_end == -1:
|
||||
break
|
||||
seed_section = MESSAGES_JS[fn_start:fn_head_end]
|
||||
|
||||
# The reconnect seed must come from INFLIGHT[activeSid].
|
||||
assert "INFLIGHT[activeSid]" in seed_section, (
|
||||
"attachLiveStream() seed section does not reference INFLIGHT[activeSid]. "
|
||||
"Reconnect must read accumulated text from durable INFLIGHT state."
|
||||
)
|
||||
assert "reconnecting" in seed_section, (
|
||||
"attachLiveStream() seed section does not branch on `reconnecting`. "
|
||||
"The reconnect path must distinguish a fresh open from a re-attach."
|
||||
)
|
||||
# The seed must NOT use querySelector to read DOM content for the initial text.
|
||||
idx = seed_section.find("let assistantText")
|
||||
assert idx != -1, "let assistantText not found in seed section — capture window too narrow or variable renamed"
|
||||
seed_up_to_assistantText = seed_section[:idx]
|
||||
assert "querySelector" not in seed_up_to_assistantText, (
|
||||
"attachLiveStream() reads DOM via querySelector before seeding assistantText. "
|
||||
"Use INFLIGHT state exclusively; DOM queries are unreliable for off-screen sessions."
|
||||
)
|
||||
|
||||
|
||||
def test_syncInflightAssistantMessage_exists_as_closure_inside_attachLiveStream():
|
||||
"""syncInflightAssistantMessage() must be defined as a closure inside attachLiveStream()
|
||||
so it closes over the live assistantText and reasoningText accumulators.
|
||||
|
||||
If it were a module-level function it could not access the per-stream state,
|
||||
breaking the token→INFLIGHT sync for every concurrent stream.
|
||||
"""
|
||||
attach_start = MESSAGES_JS.find("function attachLiveStream(")
|
||||
assert attach_start != -1, "attachLiveStream() not found."
|
||||
# syncInflightAssistantMessage definition must appear after attachLiveStream opens.
|
||||
sync_def = MESSAGES_JS.find("function syncInflightAssistantMessage(){", attach_start)
|
||||
assert sync_def != -1, (
|
||||
"syncInflightAssistantMessage() is not defined inside attachLiveStream(). "
|
||||
"It must be a closure over the stream accumulators (assistantText, reasoningText)."
|
||||
)
|
||||
# And there must NOT be a top-level definition before attachLiveStream.
|
||||
top_level_def = MESSAGES_JS.find("function syncInflightAssistantMessage(){")
|
||||
assert top_level_def == sync_def, (
|
||||
"syncInflightAssistantMessage() has a top-level definition. "
|
||||
"It must live inside attachLiveStream() to close over per-stream state."
|
||||
)
|
||||
@@ -104,6 +104,55 @@ def test_agent_session_row_exists_handles_missing_sessions_table(tmp_path, monke
|
||||
assert models.agent_session_row_exists("whatever") is True
|
||||
|
||||
|
||||
def test_agent_session_rows_existing_returns_present_subset(tmp_path, monkeypatch):
|
||||
from api import models
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
_make_state_db(home / "state.db", ["sess-a", "sess-b"])
|
||||
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
||||
|
||||
existing = models.agent_session_rows_existing(
|
||||
["sess-a", "sess-b", "sess-missing", "", None]
|
||||
)
|
||||
assert existing == frozenset({"sess-a", "sess-b"})
|
||||
|
||||
|
||||
def test_agent_session_rows_existing_safe_when_db_missing(tmp_path, monkeypatch):
|
||||
from api import models
|
||||
|
||||
monkeypatch.setattr(
|
||||
models, "_active_state_db_path", lambda: tmp_path / "nope" / "state.db"
|
||||
)
|
||||
wanted = ["orphan-a", "orphan-b"]
|
||||
assert models.agent_session_rows_existing(wanted) == frozenset(wanted)
|
||||
|
||||
|
||||
def test_agent_session_rows_existing_batches_over_500_ids(tmp_path, monkeypatch):
|
||||
from api import models
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
ids = [f"sess-{i:04d}" for i in range(600)]
|
||||
_make_state_db(home / "state.db", ids[:300])
|
||||
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
||||
|
||||
existing = models.agent_session_rows_existing(ids)
|
||||
assert existing == frozenset(ids[:300])
|
||||
|
||||
|
||||
def test_agent_session_rows_existing_normalizes_whitespace_in_probe_ids(tmp_path, monkeypatch):
|
||||
from api import models
|
||||
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
_make_state_db(home / "state.db", ["cli-padded"])
|
||||
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
|
||||
|
||||
existing = models.agent_session_rows_existing([" cli-padded "])
|
||||
assert existing == frozenset({"cli-padded"})
|
||||
|
||||
|
||||
# ── Orphan-prune decision predicate (mirrors the sidebar merge-loop guard) ──
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,15 @@ def test_minimax_fallback_provider_label():
|
||||
|
||||
# ── _PROVIDER_MODELS ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_minimax_provider_models_has_m3():
|
||||
"""_PROVIDER_MODELS['minimax'] must include MiniMax-M3."""
|
||||
models = config._PROVIDER_MODELS.get('minimax', [])
|
||||
ids = [m['id'] for m in models]
|
||||
assert 'MiniMax-M3' in ids, (
|
||||
f"MiniMax-M3 missing from _PROVIDER_MODELS['minimax']. Found: {ids}"
|
||||
)
|
||||
|
||||
|
||||
def test_minimax_provider_models_has_m2_7():
|
||||
"""_PROVIDER_MODELS['minimax'] must include MiniMax-M2.7."""
|
||||
models = config._PROVIDER_MODELS.get('minimax', [])
|
||||
|
||||
@@ -263,3 +263,30 @@ def test_lineage_report_endpoint_returns_404_for_unknown_session(tmp_path):
|
||||
routes.handle_get(handler, parsed)
|
||||
|
||||
assert captured == {"status": 404, "message": "Session not found"}
|
||||
|
||||
|
||||
def test_lineage_report_preserves_child_order_for_each_segment_parent(tmp_path):
|
||||
"""Behavioural coverage for batched child fetch: started_at DESC per parent."""
|
||||
conn = _ensure_state_db(tmp_path / "state.db")
|
||||
t0 = time.time() - 200
|
||||
try:
|
||||
_insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression")
|
||||
_insert_state_row(
|
||||
conn,
|
||||
"lineage_report_tip",
|
||||
parent="lineage_report_root",
|
||||
started_at=t0 + 6,
|
||||
ended_at=t0 + 15,
|
||||
end_reason="user_stop",
|
||||
)
|
||||
_insert_state_row(conn, "lineage_report_child_old", parent="lineage_report_tip", started_at=t0 + 8)
|
||||
_insert_state_row(conn, "lineage_report_child_new", parent="lineage_report_tip", started_at=t0 + 20)
|
||||
|
||||
report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_tip")
|
||||
|
||||
assert [child["session_id"] for child in report["children"]] == [
|
||||
"lineage_report_child_new",
|
||||
"lineage_report_child_old",
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -608,6 +608,17 @@ class TestClarifyCardTimerLogic:
|
||||
assert '\\n\\n${draft}' in body, \
|
||||
'preserved clarify drafts should be separated from existing composer text'
|
||||
|
||||
def test_stash_clarify_draft_skips_while_submit_in_flight(self):
|
||||
src = self._get_js().read_text()
|
||||
m = re.search(r'function _stashClarifyDraft.*?(?=\nfunction |\nasync function |\Z)',
|
||||
src, re.DOTALL)
|
||||
assert m, '_stashClarifyDraft function not found'
|
||||
body = m.group(0)
|
||||
assert 'classList.contains("loading")' in body, \
|
||||
'must not stash draft while a clarify submit is in flight (#3651)'
|
||||
assert body.index('classList.contains("loading")') < body.index('clarifyInput'), \
|
||||
'loading guard must short-circuit before reading the draft'
|
||||
|
||||
def test_cancel_stream_does_not_preserve_clarify_draft(self):
|
||||
src = self._get_js().read_text()
|
||||
m = re.search(r"source\.addEventListener\('cancel'.*?\n \}\);",
|
||||
|
||||
Reference in new issue
Block a user