diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4f3ef6..c2f3b81d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 61e6e2ed..2632c3f3 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -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): diff --git a/api/config.py b/api/config.py index 7e590430..a13cd745 100644 --- a/api/config.py +++ b/api/config.py @@ -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 diff --git a/api/models.py b/api/models.py index ac4de4a5..e778a9a1 100644 --- a/api/models.py +++ b/api/models.py @@ -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: diff --git a/api/providers.py b/api/providers.py index 77b9c718..23c86d9c 100644 --- a/api/providers.py +++ b/api/providers.py @@ -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: diff --git a/api/routes.py b/api/routes.py index 7bcc6828..fc3fab70 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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")) diff --git a/static/messages.js b/static/messages.js index 0f0001fd..e429df82 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; diff --git a/tests/test_custom_provider_bare_model_reasoning.py b/tests/test_custom_provider_bare_model_reasoning.py index 6d064d75..2efce295 100644 --- a/tests/test_custom_provider_bare_model_reasoning.py +++ b/tests/test_custom_provider_bare_model_reasoning.py @@ -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)" + ) + + diff --git a/tests/test_issue3040_reattach_invariant.py b/tests/test_issue3040_reattach_invariant.py new file mode 100644 index 00000000..f7b3b039 --- /dev/null +++ b/tests/test_issue3040_reattach_invariant.py @@ -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." + ) diff --git a/tests/test_issue3238_orphaned_cli_sidecar_prune.py b/tests/test_issue3238_orphaned_cli_sidecar_prune.py index 88219237..4b98275a 100644 --- a/tests/test_issue3238_orphaned_cli_sidecar_prune.py +++ b/tests/test_issue3238_orphaned_cli_sidecar_prune.py @@ -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) ── diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py index 834d1e3b..bedf3aac 100644 --- a/tests/test_minimax_provider.py +++ b/tests/test_minimax_provider.py @@ -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', []) diff --git a/tests/test_session_lineage_report.py b/tests/test_session_lineage_report.py index 0579081d..b0e76497 100644 --- a/tests/test_session_lineage_report.py +++ b/tests/test_session_lineage_report.py @@ -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() diff --git a/tests/test_sprint30.py b/tests/test_sprint30.py index 5bcdfd44..3931e98e 100644 --- a/tests/test_sprint30.py +++ b/tests/test_sprint30.py @@ -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 \}\);",