From ca157cfa69a3f2ea23864f7c924f797a60845862 Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:28 -0400 Subject: [PATCH 01/11] Keep knowledge.db's daily backup history prune_backups passed its prefix to list_backups but parsed every timestamp with _parse_backup_timestamp's default, the chats.db prefix. Every knowledge- backup therefore parsed as None: no day was ever covered, and the daily-forever bucket deleted everything older than the 10 most recent snapshots. 14 knowledge- backups on 14 distinct days lost the 4 oldest; the same fixture under chats- lost none. Both retention loops now parse under the prefix list_backups matched on, and the private parser no longer has a default to fall back to, so a bare call fails at the call site instead of silently misreading. Tests cover the distinct-days and same-day-burst policies for both prefixes and a custom-prefix take_backup. Co-Authored-By: Claude --- backend/db_backup.py | 11 ++++-- backend/tests/test_db_backup.py | 65 ++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/backend/db_backup.py b/backend/db_backup.py index 7c201bef..7c33cb4c 100644 --- a/backend/db_backup.py +++ b/backend/db_backup.py @@ -96,7 +96,7 @@ def backup_filename(timestamp: str, *, prefix: str = BACKUP_FILENAME_PREFIX) -> return f"{prefix}{timestamp}{BACKUP_FILENAME_SUFFIX}" -def _parse_backup_timestamp(path: Path, *, prefix: str = BACKUP_FILENAME_PREFIX) -> datetime | None: +def _parse_backup_timestamp(path: Path, *, prefix: str) -> datetime | None: """None for anything that isn't one of OUR OWN backup filenames - list_backups/prune_backups must never trip over an unrelated file a user (or a future feature) happens to drop into the same directory; @@ -146,6 +146,11 @@ def prune_backups(db_path: Path, *, prefix: str = BACKUP_FILENAME_PREFIX) -> lis block every other backup this call would otherwise have pruned, and the next prune_backups call (the very next take_backup) will simply try it again.""" + # Every timestamp below must be parsed under the SAME prefix list_backups + # matched on: parsed under any other, every backup reads as unparseable, + # no day is ever covered, and the daily bucket silently deletes that + # database's whole long-term history down to the recent window. That is + # why _parse_backup_timestamp has no default prefix to fall back on. backups = list_backups(db_path, prefix=prefix) # newest first recent = backups[:KEEP_MOST_RECENT] older = backups[KEEP_MOST_RECENT:] @@ -161,7 +166,7 @@ def prune_backups(db_path: Path, *, prefix: str = BACKUP_FILENAME_PREFIX) -> lis # `older` alone. covered_days: set[str] = set() for path in recent: - timestamp = _parse_backup_timestamp(path) + timestamp = _parse_backup_timestamp(path, prefix=prefix) if timestamp is not None: covered_days.add(timestamp.strftime("%Y-%m-%d")) @@ -170,7 +175,7 @@ def prune_backups(db_path: Path, *, prefix: str = BACKUP_FILENAME_PREFIX) -> lis # sliced) - the FIRST entry seen for a given day is therefore # already the newest one for that day, so a plain "seen this day # yet?" check is enough; no separate max-by-day pass is needed. - timestamp = _parse_backup_timestamp(path) + timestamp = _parse_backup_timestamp(path, prefix=prefix) if timestamp is None: continue day_key = timestamp.strftime("%Y-%m-%d") diff --git a/backend/tests/test_db_backup.py b/backend/tests/test_db_backup.py index 20704d94..ae224422 100644 --- a/backend/tests/test_db_backup.py +++ b/backend/tests/test_db_backup.py @@ -43,14 +43,14 @@ def _make_real_db(db_path, title="Hello"): conn.close() -def _write_fake_backup(db_path, timestamp: str) -> None: +def _write_fake_backup(db_path, timestamp: str, *, prefix: str = BACKUP_FILENAME_PREFIX) -> None: """Directly creates a backup FILE with a specific timestamp in its name, bypassing take_backup's own clock - the only way to deterministically test retention across many synthetic points in time without a real sleep between each one.""" backups_dir = backups_dir_for(db_path) backups_dir.mkdir(parents=True, exist_ok=True) - path = backups_dir / backup_filename(timestamp) + path = backups_dir / backup_filename(timestamp, prefix=prefix) conn = sqlite3.connect(path) try: conn.execute("CREATE TABLE chats (id INTEGER PRIMARY KEY, title TEXT)") @@ -278,6 +278,67 @@ def test_prune_keeps_one_per_calendar_day_beyond_the_recent_window(db_path): assert len(deleted) == len(recent) + len(older) - len(expected_survivors) +# backend/knowledge_store.py backs knowledge.db up under "knowledge-"; the +# retention policy has to be prefix-blind, so every prefix-sensitive prune +# test below runs under BOTH the default prefix and that real second one. +_RETENTION_PREFIXES = [BACKUP_FILENAME_PREFIX, "knowledge-"] + + +@pytest.mark.parametrize("prefix", _RETENTION_PREFIXES) +def test_prune_keeps_every_backup_on_distinct_days_forever_for_any_prefix(db_path, prefix): + # One backup per day for KEEP_MOST_RECENT + 4 consecutive UTC days: the + # newest N survive via the recent window and each of the 4 older ones is + # the sole (so newest) backup of its own day, so the daily-forever + # bucket must keep all of them - nothing at all is prunable here. + base = datetime(2026, 1, 20, 12, 0, 0, tzinfo=timezone.utc) + timestamps = [base - timedelta(days=i) for i in range(KEEP_MOST_RECENT + 4)] + for ts in timestamps: + _write_fake_backup(db_path, _ts(ts), prefix=prefix) + + deleted = prune_backups(db_path, prefix=prefix) + + assert deleted == [] + survivor_names = {p.name for p in list_backups(db_path, prefix=prefix)} + assert survivor_names == {backup_filename(_ts(ts), prefix=prefix) for ts in timestamps} + + +@pytest.mark.parametrize("prefix", _RETENTION_PREFIXES) +def test_prune_same_day_burst_keeps_exactly_the_most_recent_N_for_any_prefix(db_path, prefix): + # The recent window's own days must count as "covered" under the + # caller's prefix too - otherwise the (N+1)th-newest backup looks like + # the first one seen for today and survives as an extra daily keeper. + now = datetime(2026, 1, 20, 12, 0, 0, tzinfo=timezone.utc) + timestamps = [now - timedelta(minutes=i) for i in range(KEEP_MOST_RECENT + 5)] + for ts in timestamps: + _write_fake_backup(db_path, _ts(ts), prefix=prefix) + + deleted = prune_backups(db_path, prefix=prefix) + + assert len(deleted) == len(timestamps) - KEEP_MOST_RECENT + survivor_names = {p.name for p in list_backups(db_path, prefix=prefix)} + assert survivor_names == { + backup_filename(_ts(ts), prefix=prefix) for ts in timestamps[:KEEP_MOST_RECENT] + } + + +def test_take_backup_with_a_custom_prefix_keeps_the_daily_history_it_prunes_against(db_path): + # End to end through the real knowledge.db call path: take_backup's own + # prune pass (not just a direct prune_backups call) must apply the daily + # bucket to "knowledge-" backups instead of collapsing them to the + # recent window. + _make_real_db(db_path) + base = datetime(2026, 1, 20, 12, 0, 0, tzinfo=timezone.utc) + older_days = [base - timedelta(days=i) for i in range(1, KEEP_MOST_RECENT + 4)] + for ts in older_days: + _write_fake_backup(db_path, _ts(ts), prefix="knowledge-") + + new_backup = take_backup(db_path, prefix="knowledge-") + + survivors = list_backups(db_path, prefix="knowledge-") + assert new_backup in survivors + assert len(survivors) == len(older_days) + 1 + + def test_prune_is_idempotent_a_second_call_deletes_nothing_more(db_path): now = datetime.now(timezone.utc) # Two snapshots per calendar day among the older ones, so there is From 4dbd6e2b024d954d98b79eab95eec9cbbe68e316 Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:29 -0400 Subject: [PATCH 02/11] Never let an infinite number destroy a reply or fail a chat load JSON 1e999 parses to float('inf'), and int(inf) raises OverflowError, which none of the int coercions caught: - normalize_usage runs at every provider's done event, after the reply has already streamed, so one infinite token count from a server or proxy discarded the whole reply. - graphlink_model_catalog._as_int failed an Ollama model descriptor the same way. - session_load's budget, counter, elapsedMs and total_session_tokens coercions failed the restorer (or the whole chat, for the session total) on a corrupted or hand-edited row, since chats.db rows are written with json.dumps' default allow_nan=True. Each now treats OverflowError like the TypeError/ValueError it already tolerated. Tests cover inf, -inf, nan, Decimal('Infinity') and huge finite values for both coercers, and a saved plan, harness and chat carrying infinite numbers. Co-Authored-By: Claude --- backend/providers/base.py | 7 +- backend/session_load.py | 10 +-- backend/tests/test_session_load.py | 30 ++++++++ backend/tests/test_token_count_coercion.py | 83 ++++++++++++++++++++++ graphlink_model_catalog.py | 6 +- 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_token_count_coercion.py diff --git a/backend/providers/base.py b/backend/providers/base.py index 55ccea56..db08c5b5 100644 --- a/backend/providers/base.py +++ b/backend/providers/base.py @@ -249,9 +249,14 @@ def normalize_usage(prompt_tokens, completion_tokens) -> dict | None: "completion_tokens": int | None}, or None when the provider reported nothing at all. Tolerates non-int server values (returns None fields).""" def _as_int(value): + # Every provider calls this at its done event, after the reply text + # has already streamed, so it must never raise: an exception here + # discards the whole reply over a bad counter. OverflowError is the + # non-obvious member - JSON `1e999` parses to float('inf') and + # int(inf) raises it (int(nan) is the ValueError case). try: return int(value) if value is not None else None - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return None prompt = _as_int(prompt_tokens) diff --git a/backend/session_load.py b/backend/session_load.py index 93290abf..b2180abb 100644 --- a/backend/session_load.py +++ b/backend/session_load.py @@ -386,7 +386,7 @@ def _non_negative_int(value: Any, default: int = 0) -> int: straight into int(), which is exactly the shape that raises.""" try: return max(0, int(value)) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return default @@ -411,7 +411,7 @@ def _positive_int(value: Any, default: int) -> int: default here.""" try: parsed = int(value) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return default return parsed if parsed > 0 else default @@ -931,7 +931,7 @@ def _restore_plan_payload(payload: dict[str, Any]) -> SceneNode: # whole session load the way a bare int() would. try: elapsed_ms = int(raw.get("elapsedMs", 0) or 0) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): elapsed_ms = 0 activity.append({ "tool": str(raw.get("tool", "")), @@ -988,7 +988,7 @@ def _restore_harness_payload(payload: dict[str, Any]) -> SceneNode: if isinstance(raw, dict) and raw.get("tool"): try: elapsed_ms = int(raw.get("elapsedMs", 0) or 0) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): elapsed_ms = 0 activity.append({ "tool": str(raw.get("tool", "")), @@ -1929,7 +1929,7 @@ def _restore_chat_into_document( total_tokens = chat_data.get("total_session_tokens", 0) try: document.total_session_tokens = int(total_tokens) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): document.total_session_tokens = 0 # ADR-002 Workstream 1 ("Branch status and lifecycle"): translated diff --git a/backend/tests/test_session_load.py b/backend/tests/test_session_load.py index 7a180b43..74c89520 100644 --- a/backend/tests/test_session_load.py +++ b/backend/tests/test_session_load.py @@ -183,6 +183,36 @@ def test_plan_node_survives_non_numeric_budget_fields(): assert node.state.builder_spent_steps == 0 +@pytest.mark.parametrize("infinite", [float("inf"), float("-inf")]) +def test_infinite_numbers_in_a_saved_chat_degrade_instead_of_failing_the_load(infinite): + """chats.db rows are written with json.dumps' default allow_nan=True, and + json.loads reads 1e999/Infinity back as float('inf') - int() of that + raises OverflowError, not ValueError. A hand-edited or corrupted row must + degrade the same way a non-numeric one does: the plan node stays, its + caps fall back to their defaults, and the chat itself still opens.""" + document = _restore( + nodes=[{ + "node_type": "plan", "position": {"x": 0.0, "y": 0.0}, "goal": "ship it", + "max_steps": infinite, "spent_tokens": infinite, + "activity": [{"tool": "fs.read", "summary": "s", "elapsedMs": infinite}], + }, { + "node_type": "harness", "position": {"x": 0.0, "y": 0.0}, "goal": "g", + "max_turns": infinite, "spent_turns": infinite, + "activity": [{"tool": "shell", "summary": "s", "elapsedMs": infinite}], + }], + total_session_tokens=infinite, + ) + plan = next(n for n in document.nodes.values() if n.kind == "plan") + harness = next(n for n in document.nodes.values() if n.kind == "harness") + assert plan.state.builder_max_steps == 12 + assert plan.state.builder_spent_tokens == 0 + assert [entry["elapsedMs"] for entry in plan.state.builder_activity] == [0] + assert harness.state.harness_max_turns == 16 + assert harness.state.harness_spent_turns == 0 + assert [entry["elapsedMs"] for entry in harness.state.harness_activity] == [0] + assert document.total_session_tokens == 0 + + def test_a_restorer_that_raises_is_logged_not_swallowed_in_silence(caplog): """The node is still dropped - one bad row must not fail the whole load - but it no longer disappears without a trace.""" diff --git a/backend/tests/test_token_count_coercion.py b/backend/tests/test_token_count_coercion.py new file mode 100644 index 00000000..497e287e --- /dev/null +++ b/backend/tests/test_token_count_coercion.py @@ -0,0 +1,83 @@ +"""Integer coercion of provider-reported counts that arrive as JSON numbers. + +backend/providers/base.py's normalize_usage runs at every provider's done +event, AFTER the reply text has already streamed - an exception there +throws the whole reply away. graphlink_model_catalog's _as_int reads the +same kind of untrusted server number (Ollama model sizes and context +lengths). Both promise "a value that isn't a usable int becomes None", +and JSON can legitimately carry values int() refuses with something other +than TypeError/ValueError: `1e999` parses to float('inf'), and int(inf) +raises OverflowError.""" + +from __future__ import annotations + +import json +from decimal import Decimal + +import pytest + +import graphlink_model_catalog as mc +from backend.providers.base import normalize_usage + +# Values int() cannot turn into a finite count, each paired with the +# exception int() itself raises for it - the point of listing both is that +# the helpers must absorb every one of these, not just the ones that +# happened to be in the except tuple first. +_UNCONVERTIBLE = [ + pytest.param(json.loads("1e999"), id="json-1e999-is-inf"), # OverflowError + pytest.param(float("inf"), id="inf"), # OverflowError + pytest.param(float("-inf"), id="-inf"), # OverflowError + pytest.param(float("nan"), id="nan"), # ValueError + pytest.param(Decimal("Infinity"), id="decimal-infinity"), # OverflowError + pytest.param("4.5", id="non-integral-string"), # ValueError + pytest.param("lots", id="non-numeric-string"), # ValueError + pytest.param({"tokens": 3}, id="object"), # TypeError +] + + +@pytest.mark.parametrize("value", _UNCONVERTIBLE) +def test_normalize_usage_turns_an_unconvertible_completion_count_into_none(value): + assert normalize_usage(None, value) is None + assert normalize_usage(12, value) == {"prompt_tokens": 12, "completion_tokens": None} + + +@pytest.mark.parametrize("value", _UNCONVERTIBLE) +def test_normalize_usage_turns_an_unconvertible_prompt_count_into_none(value): + assert normalize_usage(value, None) is None + assert normalize_usage(value, 7) == {"prompt_tokens": None, "completion_tokens": 7} + + +def test_normalize_usage_keeps_every_value_int_can_represent(): + assert normalize_usage(None, None) is None + assert normalize_usage("42", 3.0) == {"prompt_tokens": 42, "completion_tokens": 3} + # Huge but FINITE is still a real integer, not an overflow. + assert normalize_usage(1e300, 0) == {"prompt_tokens": int(1e300), "completion_tokens": 0} + + +@pytest.mark.parametrize("value", _UNCONVERTIBLE) +def test_catalog_as_int_turns_an_unconvertible_value_into_none(value): + assert mc._as_int(value) is None + + +def test_catalog_as_int_keeps_every_value_int_can_represent(): + assert mc._as_int(None) is None + assert mc._as_int("4096") == 4096 + assert mc._as_int(8192.0) == 8192 + assert mc._as_int(1e300) == int(1e300) + + +def test_ollama_descriptor_survives_infinite_size_and_context_length(): + # One malformed model entry must not take the whole catalog listing + # down with it - the descriptor is still built, just without the + # unusable numbers. + model = json.loads( + '{"model": "llama3:8b", "size": 1e999,' + ' "details": {"family": "llama", "context_length": 1e999},' + ' "model_info": {"llama.context_length": -1e999}}' + ) + + descriptor = mc.ollama_descriptor(model) + + assert descriptor.model_id == "llama3:8b" + assert descriptor.size_bytes is None + assert descriptor.context_length is None diff --git a/graphlink_model_catalog.py b/graphlink_model_catalog.py index b71a01fb..df6009b4 100644 --- a/graphlink_model_catalog.py +++ b/graphlink_model_catalog.py @@ -137,9 +137,13 @@ def _field(value, name: str, default=None): def _as_int(value) -> int | None: + # Reads untrusted server numbers (Ollama sizes, context lengths): any + # value int() refuses becomes None rather than failing the whole + # descriptor. OverflowError covers JSON `1e999`, which parses to + # float('inf'); int(nan) is already a ValueError. try: return int(value) if value is not None else None - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): return None From bdbc2e01ec94dfd2e4cb529fb714d89cf06a53c7 Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:29 -0400 Subject: [PATCH 03/11] Run the llama.cpp model a node is pinned to A llama.cpp ModelRef pin was validated against the two configured slots' basenames, then LlamaCppProvider was built with no model at all, and every llama_cpp_runtime helper resolves the model by task. A chat node pinned to the title slot's model was answered by the chat slot's model and attributed to the pinned one, so a side-by-side comparison compared a model with itself. _provider_for_model_ref now resolves the pin to its configured full path and LlamaCppProvider points both slots of its own settings copy at it, so the client cache, reasoning detection and every task use the pinned model. Two different files sharing the pinned basename are refused as ambiguous; the same file reached through a link is not. The describe_active_model provenance pair and AgentDispatcher's active_provider_model accept the resolved ref, so the reply badge and cost lookup can name the model that actually ran. Co-Authored-By: Claude --- api_provider.py | 98 +++++++-- backend/agent_dispatch/core.py | 11 +- backend/providers/llama_cpp_provider.py | 14 +- backend/tests/test_llama_cpp_model_pin.py | 221 +++++++++++++++++++++ backend/tests/test_model_ref_provenance.py | 91 +++++++++ 5 files changed, 418 insertions(+), 17 deletions(-) create mode 100644 backend/tests/test_llama_cpp_model_pin.py create mode 100644 backend/tests/test_model_ref_provenance.py diff --git a/api_provider.py b/api_provider.py index bc4e20e0..7e991225 100644 --- a/api_provider.py +++ b/api_provider.py @@ -1207,6 +1207,58 @@ def generate_image(prompt: str, size: str = "1024x1024", *, runtime=None) -> byt attempt += 1 +def _resolve_llama_cpp_pinned_model_path(model_id: str, llama_cpp_settings: dict) -> str: + """ADR-018: the configured full path a llama.cpp ModelRef pin names. + + A llama.cpp pin identifies its model by BASENAME - the only identity + the catalog and describe_active_model expose for llama.cpp - while every + llama_cpp_runtime helper resolves the model by TASK. Validating the + basename alone therefore let a chat turn pinned to the title slot's + model be answered by the chat slot's model, attributed to the pinned + one. Resolving to the slot's full path here lets the caller build the + provider around exactly that file. + + Both slots holding DIFFERENT files that share the pinned basename is + refused rather than guessed at: the pin cannot say which file was meant, + and running either would be the same silent substitution this function + exists to prevent. The same file in both slots (however spelled or + linked) is not ambiguous. Empty slots (no title override configured) + never match.""" + matches: list[str] = [] + for key in ("chat_model_path", "title_model_path"): + path = str(llama_cpp_settings.get(key) or "") + if path and Path(path).name == model_id and not any(_is_same_file(path, seen) for seen in matches): + # One file configured in both slots is one match; the chat + # slot's spelling wins. + matches.append(path) + if not matches: + raise RuntimeError( + f"'{model_id}' is not one of this session's configured " + "Llama.cpp model paths. Configure it in Settings > Llama.cpp first." + ) + if len(matches) > 1: + raise RuntimeError( + f"'{model_id}' matches more than one configured Llama.cpp model file " + f"({' and '.join(matches)}). Rename one of the files so the " + "pinned model names exactly one of them." + ) + return matches[0] + + +def _is_same_file(path_a: str, path_b: str) -> bool: + """Whether two configured paths name one file. Normalized spelling + first (works for files that are not there yet), then the filesystem's + own identity: a symlink, junction, hard link, 8.3 short name or subst/ + mapped drive reaches the same file under a different spelling, and + treating those as two models would refuse a pin that is not ambiguous.""" + if os.path.normcase(os.path.abspath(path_a)) == os.path.normcase(os.path.abspath(path_b)): + return True + try: + return os.path.samefile(path_a, path_b) + except OSError: # either file missing/unreadable: spelling was the only evidence + return False + + def _provider_for_model_ref(model_ref: ModelRef, state: "_ProviderSnapshot"): """ADR-018 stage 18.1: construct the provider a resolved ModelRef names, the alternative to chat()/chat_stream()'s own task-keyed branch-select @@ -1234,7 +1286,10 @@ def _provider_for_model_ref(model_ref: ModelRef, state: "_ProviderSnapshot"): state.llama_cpp_settings (chat_model_path/title_model_path) - llama.cpp has no "many installed models" catalog the way Ollama does, so free model_id selection isn't meaningful there; a mismatch raises the same - actionable-error posture as an unconfigured cloud override.""" + actionable-error posture as an unconfigured cloud override. The match is + resolved to that slot's full path and the provider is pinned to it (see + _resolve_llama_cpp_pinned_model_path), so the pinned model answers for + every task rather than whichever model the task's own slot holds.""" if model_ref.provider == config.LOCAL_PROVIDER_OLLAMA: from backend.providers.ollama_provider import OllamaProvider @@ -1245,18 +1300,10 @@ def _provider_for_model_ref(model_ref: ModelRef, state: "_ProviderSnapshot"): ) if model_ref.provider == config.LOCAL_PROVIDER_LLAMACPP: - configured_paths = { - Path(str(state.llama_cpp_settings.get("chat_model_path") or "")).name, - Path(str(state.llama_cpp_settings.get("title_model_path") or "")).name, - } - if model_ref.model_id not in configured_paths: - raise RuntimeError( - f"'{model_ref.model_id}' is not one of this session's configured " - "Llama.cpp model paths. Configure it in Settings > Llama.cpp first." - ) + pinned_path = _resolve_llama_cpp_pinned_model_path(model_ref.model_id, state.llama_cpp_settings) from backend.providers.llama_cpp_provider import LlamaCppProvider - return LlamaCppProvider(settings=state.llama_cpp_settings) + return LlamaCppProvider(settings=state.llama_cpp_settings, model_path=pinned_path) if model_ref.provider in (config.API_PROVIDER_OPENAI, config.API_PROVIDER_ANTHROPIC, config.API_PROVIDER_GEMINI): if not (state.use_api_mode and state.api_provider_type == model_ref.provider and state.api_client): @@ -2255,12 +2302,37 @@ def chat_turn_with_tools(task: str, messages: list, tools: tuple = (), **kwargs) _translate_chat_exception(exc, state, messages) -def describe_active_model(task: str, runtime: "ProviderRuntime | None" = None) -> tuple[str, str]: +# ModelRef.provider -> the provenance label the unpinned branches of +# describe_active_model report for that provider. Cloud providers are +# absent on purpose: API mode already reports the config type string, which +# is exactly what ModelRef.provider holds. +_MODEL_REF_PROVENANCE_LABELS = { + config.LOCAL_PROVIDER_OLLAMA: "ollama", + config.LOCAL_PROVIDER_LLAMACPP: "llama.cpp", +} + + +def describe_active_model( + task: str, runtime: "ProviderRuntime | None" = None, model_ref: "ModelRef | None" = None, +) -> tuple[str, str]: """ADR-006 stage 6.8: (provider, model) for `task` under the given runtime's current snapshot (default session when runtime is None) - the provenance pair intents_chat stamps onto reply nodes and hands the token counter for cost estimation. Local providers report "ollama" / - "llama.cpp"; API mode reports the configured provider type string.""" + "llama.cpp"; API mode reports the configured provider type string. + + `model_ref` (ADR-018): the ref a pinned dispatch resolved. That dispatch + runs exactly the ref's model (_provider_for_model_ref), not the task's + configured one, so the ref IS the provenance - reported in the same + label convention as the unpinned branches, so a pin to the model the + task would have used anyway yields identical provenance and the same + cost lookup. A llama.cpp ref's model_id is already the basename the + unpinned branch reports.""" + if model_ref is not None: + return ( + _MODEL_REF_PROVENANCE_LABELS.get(model_ref.provider, model_ref.provider), + model_ref.model_id, + ) state = (runtime if runtime is not None else DEFAULT_RUNTIME).snapshot() if state.use_api_mode: return (state.api_provider_type or "", state.api_models.get(task) or "") diff --git a/backend/agent_dispatch/core.py b/backend/agent_dispatch/core.py index d7f383c8..c1b9c5f0 100644 --- a/backend/agent_dispatch/core.py +++ b/backend/agent_dispatch/core.py @@ -239,13 +239,18 @@ def _runtime_kwargs(self) -> dict: # -- ADR-008 stage 8.3: the Builder -------------------------------------- - def active_provider_model(self) -> tuple[str, str]: + def active_provider_model(self, model_ref=None) -> tuple[str, str]: """ADR-006 stage 6.8: the (provider, model) pair a chat dispatch would use right now, from THIS session's runtime (default session -> module-backed DEFAULT_RUNTIME). intents_chat stamps it onto reply - nodes and hands it to the token counter for cost estimation.""" + nodes and hands it to the token counter for cost estimation. + `model_ref` is the dispatch's resolved node/branch/workspace pin + (_resolve_model_ref_for_dispatch), when it has one: the pair then + names the pinned model that actually runs, not the task's default.""" from backend import agents as agents_module # deferred: patch-seam + circular-import safety - return agents_module.api_provider.describe_active_model(agents_module.config.TASK_CHAT, self._provider_runtime) + return agents_module.api_provider.describe_active_model( + agents_module.config.TASK_CHAT, self._provider_runtime, model_ref=model_ref, + ) def persona(self) -> str: """Mirror legacy graphlink_window.py's `_get_current_system_prompt`: diff --git a/backend/providers/llama_cpp_provider.py b/backend/providers/llama_cpp_provider.py index 3356c301..9608f88a 100644 --- a/backend/providers/llama_cpp_provider.py +++ b/backend/providers/llama_cpp_provider.py @@ -41,11 +41,23 @@ class LlamaCppProvider: - def __init__(self, *, settings: dict): + def __init__(self, *, settings: dict, model_path: str | None = None): # The whole settings dict (paths, n_ctx, chat_format, reasoning # level) is this provider's configuration - captured from the # caller's snapshot, same immutability contract as every provider # in this package. + # + # `model_path` (ADR-018 pin, from api_provider._provider_for_model_ref): + # every llama_cpp_runtime helper resolves the model BY TASK from the + # two configured slots, so a pin is honored by pointing BOTH slots of + # this instance's own copy at the pinned file. The client cache key, + # the Qwen /think detection and the reasoning capability below then + # all see the pinned model for every task, instead of a chat turn + # being answered by whatever the chat slot holds. None keeps the + # task-keyed resolution untouched; the caller's snapshot is never + # mutated. + if model_path: + settings = {**settings, "chat_model_path": model_path, "title_model_path": model_path} self.settings = settings self.last_usage: dict | None = None # ADR-006 stage 6.8 - see complete() self.capabilities = ProviderCapabilities( diff --git a/backend/tests/test_llama_cpp_model_pin.py b/backend/tests/test_llama_cpp_model_pin.py new file mode 100644 index 00000000..37be262d --- /dev/null +++ b/backend/tests/test_llama_cpp_model_pin.py @@ -0,0 +1,221 @@ +"""ADR-018: a llama.cpp ModelRef pin must be the model that actually runs. + +_provider_for_model_ref accepts a llama.cpp pin by the BASENAME of one of +the session's two configured paths (chat_model_path / title_model_path), +while every downstream lookup in provider_runtime/llama_cpp_runtime.py +resolves the model by TASK. A pin that only passed validation and then let +the task pick the model would silently answer a "tinyllama" node with the +chat model - and the model-comparison workflow ADR-018 exists for would +compare a model with itself. + +These tests drive the real resolution chain all the way down to the +Llama(...) construction; only the native class is faked, so the model path +asserted is the one that would actually be loaded. +""" + +from __future__ import annotations + +import os + +import pytest + +import api_provider +import graphlink_task_config as config +from graphlink_model_catalog import ModelRef + +# Captured at import time, before the autouse conftest fixture swaps +# api_provider.chat_stream for its one-chunk stub - see test_providers.py's +# own identical pattern/comment. +_REAL_CHAT_STREAM = api_provider.chat_stream + +_CHAT_NAME = "qwen3-8b.gguf" # a Qwen model: the /think directive applies +_TITLE_NAME = "tinyllama.gguf" # not a reasoning model: no directive + + +class _LlamaEnv: + def __init__(self, chat_path: str, title_path: str): + self.paths = {_CHAT_NAME: chat_path, _TITLE_NAME: title_path} + self.loaded: list[str] = [] # model_path of every Llama(...) construction + self.calls: list[tuple[str, list]] = [] # (model_path, messages) per generation + + +def _write_gguf(path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"GGUF") + return os.path.abspath(str(path)) + + +@pytest.fixture +def llama_env(monkeypatch, tmp_path): + env = _LlamaEnv( + _write_gguf(tmp_path / "chat" / _CHAT_NAME), + _write_gguf(tmp_path / "title" / _TITLE_NAME), + ) + + class FakeLlama: + def __init__(self, *, model_path, **_kwargs): + self.model_path = model_path + env.loaded.append(model_path) + + def create_chat_completion(self, messages=None, stream=False, **_kwargs): + env.calls.append((self.model_path, messages)) + name = os.path.basename(self.model_path) + if stream: + return iter([ + {"choices": [{"delta": {"content": f"from {name}"}, "finish_reason": None}]}, + {"choices": [{"delta": {}, "finish_reason": "stop"}]}, + ]) + return {"choices": [{"message": {"content": f"from {name}"}}]} + + monkeypatch.setattr(api_provider, "_load_llama_cpp_class", lambda: FakeLlama) + monkeypatch.setattr(api_provider, "_LLAMA_CPP_CLIENT_CACHE", {}) + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_LLAMACPP) + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "chat_model_path", env.paths[_CHAT_NAME]) + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "title_model_path", env.paths[_TITLE_NAME]) + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "reasoning_level", "high") + return env + + +def _pin(model_id: str) -> ModelRef: + return ModelRef(config.LOCAL_PROVIDER_LLAMACPP, model_id) + + +def _has_qwen_directive(messages: list) -> bool: + return any( + message.get("role") == "system" + and str(message.get("content") or "").lstrip().startswith(("/think", "/no_think")) + for message in messages + ) + + +_PIN_CASES = [ + (_CHAT_NAME, config.TASK_CHAT), + (_CHAT_NAME, config.TASK_TITLE), + (_TITLE_NAME, config.TASK_CHAT), + (_TITLE_NAME, config.TASK_TITLE), +] + + +@pytest.mark.parametrize("pinned, task", _PIN_CASES) +def test_blocking_chat_runs_the_pinned_model_for_every_task(llama_env, pinned, task): + response = api_provider.chat(task, [{"role": "user", "content": "hi"}], model_ref=_pin(pinned)) + + assert llama_env.calls[-1][0] == llama_env.paths[pinned] + assert response["message"]["content"] == f"from {pinned}" + + +@pytest.mark.parametrize("pinned, task", _PIN_CASES) +def test_streaming_chat_runs_the_pinned_model_for_every_task(llama_env, pinned, task): + chunks = [] + response = _REAL_CHAT_STREAM( + task, [{"role": "user", "content": "hi"}], lambda delta, _reset: chunks.append(delta), + model_ref=_pin(pinned), + ) + + assert llama_env.calls[-1][0] == llama_env.paths[pinned] + assert response["message"]["content"] == f"from {pinned}" + assert "".join(chunks) == f"from {pinned}" + + +def test_unpinned_requests_still_resolve_the_model_by_task(llama_env): + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}]) + assert llama_env.calls[-1][0] == llama_env.paths[_CHAT_NAME] + + api_provider.chat(config.TASK_TITLE, [{"role": "user", "content": "hi"}]) + assert llama_env.calls[-1][0] == llama_env.paths[_TITLE_NAME] + + +def test_a_pinned_model_shares_the_cached_client_its_unpinned_task_uses(llama_env): + """The client cache is keyed by the resolved model path, so a pin must + land on the SAME cached Llama an unpinned request for that model uses - + never a second multi-GB load of a model that is already resident.""" + api_provider.chat(config.TASK_TITLE, [{"role": "user", "content": "hi"}]) + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], model_ref=_pin(_TITLE_NAME)) + + assert llama_env.loaded.count(llama_env.paths[_TITLE_NAME]) == 1 + assert llama_env.paths[_CHAT_NAME] not in llama_env.loaded + + +def test_the_qwen_thinking_directive_follows_the_pinned_model_not_the_task_slot(llama_env): + """Message prep decides whether to inject Qwen's /think directive from + the model path - a non-Qwen pin on a chat turn must not receive it just + because the session's CHAT slot happens to hold a Qwen model.""" + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], model_ref=_pin(_TITLE_NAME)) + assert not _has_qwen_directive(llama_env.calls[-1][1]) + + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], model_ref=_pin(_CHAT_NAME)) + assert _has_qwen_directive(llama_env.calls[-1][1]) + + +def test_the_pinned_provider_reports_the_pinned_models_reasoning_capability(llama_env): + state = api_provider._snapshot_provider_state() + + assert api_provider._provider_for_model_ref(_pin(_CHAT_NAME), state).capabilities.reasoning is True + assert api_provider._provider_for_model_ref(_pin(_TITLE_NAME), state).capabilities.reasoning is False + + +def test_a_basename_shared_by_two_different_configured_files_is_rejected(llama_env, monkeypatch, tmp_path): + """Two different files with the same name cannot be told apart by a + basename pin - running either one would be a guess the user never + made. The pin is refused with an actionable error instead.""" + other = _write_gguf(tmp_path / "elsewhere" / _CHAT_NAME) + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "title_model_path", other) + + with pytest.raises(RuntimeError, match="matches more than one"): + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], model_ref=_pin(_CHAT_NAME)) + assert llama_env.calls == [] + + +def test_the_same_file_configured_in_both_slots_is_not_ambiguous(llama_env, monkeypatch): + # Spelled differently (a redundant path segment) but the same file. + chat_path = llama_env.paths[_CHAT_NAME] + respelled = os.path.join(os.path.dirname(chat_path), ".", _CHAT_NAME) + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "title_model_path", respelled) + + api_provider.chat(config.TASK_TITLE, [{"role": "user", "content": "hi"}], model_ref=_pin(_CHAT_NAME)) + assert llama_env.calls[-1][0] == chat_path + + +@pytest.mark.parametrize("link", ["hardlink", "symlink"]) +def test_one_file_reached_through_a_link_from_both_slots_is_not_ambiguous(llama_env, monkeypatch, tmp_path, link): + """Path normalization cannot see that a link (or a junction, an 8.3 + short name, a subst drive) reaches the same file the other slot names - + refusing that pin would block a configuration holding one model.""" + chat_path = llama_env.paths[_CHAT_NAME] + linked = tmp_path / "linked" / _CHAT_NAME + linked.parent.mkdir() + try: + (os.link if link == "hardlink" else os.symlink)(chat_path, linked) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"cannot create a {link} here: {exc}") + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "title_model_path", str(linked)) + + api_provider.chat(config.TASK_TITLE, [{"role": "user", "content": "hi"}], model_ref=_pin(_CHAT_NAME)) + assert llama_env.calls[-1][0] == chat_path + + +@pytest.mark.parametrize("pinned, task", _PIN_CASES) +def test_provenance_for_a_pinned_dispatch_names_the_model_that_ran(llama_env, pinned, task): + """describe_active_model feeds the reply's model badge and the token + counter's cost lookup; for a pinned dispatch it must name the pinned + model that actually answered, not whatever the task's slot holds.""" + api_provider.chat(task, [{"role": "user", "content": "hi"}], model_ref=_pin(pinned)) + + ran = os.path.basename(llama_env.calls[-1][0]) + assert api_provider.describe_active_model(task, model_ref=_pin(pinned)) == ("llama.cpp", ran) + # Unpinned provenance keeps reading the task's own slot. + assert api_provider.describe_active_model(config.TASK_CHAT) == ("llama.cpp", _CHAT_NAME) + assert api_provider.describe_active_model(config.TASK_TITLE) == ("llama.cpp", _TITLE_NAME) + + +def test_an_unconfigured_or_empty_pin_is_rejected(llama_env, monkeypatch): + with pytest.raises(RuntimeError, match="not one of this session's configured"): + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], model_ref=_pin("other.gguf")) + + # No title override configured: the empty title slot must not make an + # empty model_id look like a configured model. + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "title_model_path", "") + with pytest.raises(RuntimeError, match="not one of this session's configured"): + api_provider.chat(config.TASK_CHAT, [{"role": "user", "content": "hi"}], model_ref=_pin("")) + assert llama_env.calls == [] diff --git a/backend/tests/test_model_ref_provenance.py b/backend/tests/test_model_ref_provenance.py new file mode 100644 index 00000000..11d3526d --- /dev/null +++ b/backend/tests/test_model_ref_provenance.py @@ -0,0 +1,91 @@ +"""ADR-018: a pinned dispatch's provenance names the pinned model. + +describe_active_model is the (provider, model) pair stamped on reply nodes +(the chat-node model badge) and handed to the token counter for cost +estimation. Unpinned, it reads the task's configured model. A pinned +dispatch runs exactly the ModelRef's model instead (_provider_for_model_ref), +so given that ref it must report the ref - in the SAME label convention the +unpinned branches use, so a pin to the model the session would have picked +anyway produces byte-identical provenance and the same cost lookup. +""" + +from __future__ import annotations + +import pytest + +import api_provider +import graphlink_task_config as config +from graphlink_model_catalog import ModelRef + + +def _configure_ollama(monkeypatch, chat_model: str) -> None: + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) + monkeypatch.setitem(config.OLLAMA_MODELS, config.TASK_CHAT, chat_model) + + +def _configure_llama_cpp(monkeypatch, chat_path: str) -> None: + monkeypatch.setattr(api_provider, "USE_API_MODE", False) + monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_LLAMACPP) + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "chat_model_path", chat_path) + monkeypatch.setitem(api_provider.LLAMA_CPP_SETTINGS, "title_model_path", "") + + +def _configure_cloud(provider: str): + def configure(monkeypatch, chat_model: str) -> None: + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", provider) + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_CHAT, chat_model) + + return configure + + +# (configure the session, the model it is configured with, a ref naming that +# same model, a ref naming a DIFFERENT model of the same provider) +_CASES = [ + pytest.param( + _configure_ollama, "llama3:8b", + ModelRef(config.LOCAL_PROVIDER_OLLAMA, "llama3:8b"), ModelRef(config.LOCAL_PROVIDER_OLLAMA, "qwen3:14b"), + id="ollama", + ), + pytest.param( + _configure_llama_cpp, "/models/qwen3-8b.gguf", + ModelRef(config.LOCAL_PROVIDER_LLAMACPP, "qwen3-8b.gguf"), + ModelRef(config.LOCAL_PROVIDER_LLAMACPP, "tinyllama.gguf"), + id="llama.cpp", + ), + *[ + pytest.param( + _configure_cloud(provider), "model-a", ModelRef(provider, "model-a"), ModelRef(provider, "model-b"), + id=provider, + ) + for provider in (config.API_PROVIDER_OPENAI, config.API_PROVIDER_ANTHROPIC, config.API_PROVIDER_GEMINI) + ], +] + + +@pytest.mark.parametrize("configure, configured_model, same_ref, other_ref", _CASES) +def test_a_pin_to_the_configured_model_reports_exactly_the_unpinned_provenance( + monkeypatch, configure, configured_model, same_ref, other_ref, +): + configure(monkeypatch, configured_model) + unpinned = api_provider.describe_active_model(config.TASK_CHAT) + + assert api_provider.describe_active_model(config.TASK_CHAT, model_ref=same_ref) == unpinned + + +@pytest.mark.parametrize("configure, configured_model, same_ref, other_ref", _CASES) +def test_a_pin_to_another_model_reports_the_pinned_model_not_the_tasks( + monkeypatch, configure, configured_model, same_ref, other_ref, +): + configure(monkeypatch, configured_model) + unpinned_provider, _unpinned_model = api_provider.describe_active_model(config.TASK_CHAT) + + assert api_provider.describe_active_model(config.TASK_CHAT, model_ref=other_ref) == ( + unpinned_provider, other_ref.model_id, + ) + + +def test_no_ref_keeps_the_task_keyed_lookup(monkeypatch): + _configure_ollama(monkeypatch, "llama3:8b") + assert api_provider.describe_active_model(config.TASK_CHAT, model_ref=None) == ("ollama", "llama3:8b") From d11f1288cf7a818734880d412bfda36b2b76e40b Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:29 -0400 Subject: [PATCH 04/11] Refuse a streamed reply that never said it finished AnthropicProvider raises when a stream ends without message_stop, and the dispatch layer then lands the streamed partial flagged incomplete. Gemini, OpenAI-compatible and Ollama streams had no such check: a proxy or a gracefully closed connection that ended the stream mid-answer committed half a reply as the finished turn. Each now records its provider's own finish marker - Gemini's candidates[].finishReason, OpenAI's choices[].finish_reason (read before the include_usage chunk with empty choices), Ollama's done:true or its terminal tool_calls chunk - and raises the same unexpected-end error when the stream stops without one. Tool-call-only turns and length finishes still complete. The OpenAI and Gemini fakes in test_providers and test_tool_calling now carry the finish marker every real server sends. Co-Authored-By: Claude --- backend/providers/gemini_provider.py | 21 ++ backend/providers/ollama_provider.py | 21 ++ backend/providers/openai_provider.py | 23 ++ .../tests/test_ollama_stream_completion.py | 82 +++++ .../tests/test_provider_stream_completion.py | 335 ++++++++++++++++++ backend/tests/test_providers.py | 21 +- backend/tests/test_tool_calling.py | 25 +- 7 files changed, 508 insertions(+), 20 deletions(-) create mode 100644 backend/tests/test_ollama_stream_completion.py create mode 100644 backend/tests/test_provider_stream_completion.py diff --git a/backend/providers/gemini_provider.py b/backend/providers/gemini_provider.py index bd630433..ae1d9dcf 100644 --- a/backend/providers/gemini_provider.py +++ b/backend/providers/gemini_provider.py @@ -161,6 +161,12 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider text_parts: list[str] = [] tool_calls: list[ToolCall] = [] usage = None + # A finished Gemini generation always reports why it stopped: a + # candidates[].finishReason (STOP - function-call turns included + # - MAX_TOKENS, SAFETY, ...) on its last frame; intermediate + # frames carry none. Same sentinel posture as + # AnthropicProvider.stream()'s message_stop. + saw_finish_reason = False sse = _gemini_stream_sse( f"{GEMINI_BASE_URL}/v1beta/models/{self.model_id}:streamGenerateContent?alt=sse", request_body, @@ -201,6 +207,9 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider f"The response was blocked by Google's Safety Filters ({block_reason})." ) for candidate in payload.get("candidates", []) or []: + # snake_case tolerated, mirroring block_reason above. + if candidate.get("finishReason") or candidate.get("finish_reason"): + saw_finish_reason = True content = candidate.get("content", {}) or {} for part in content.get("parts", []) or []: # ADR-007 stage 7.1: Gemini delivers a function @@ -232,6 +241,18 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider finally: sse.close() # idempotent on an already-closed generator + # An SSE stream that just stops without a finishReason (proxy + # truncation, a connection closed gracefully mid-answer) + # delivered a fragment - returning it as done would commit half + # a reply as the finished turn. Raising lets the dispatch layer + # land the streamed partial flagged incomplete instead. Checked + # ahead of the empty-response error below: a truncated stream + # with no text yet is still a truncation. + if not saw_finish_reason: + raise RuntimeError( + "Gemini stream ended unexpectedly before completion. Please try again." + ) + # Deliberate parity with complete(): _extract_gemini_text # concatenates EVERY text part, thought or not, and never calls # _compose_reasoned_response - Gemini has no composition diff --git a/backend/providers/ollama_provider.py b/backend/providers/ollama_provider.py index 7cbbe1d2..048a831a 100644 --- a/backend/providers/ollama_provider.py +++ b/backend/providers/ollama_provider.py @@ -225,6 +225,12 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider thinking_parts: list[str] = [] tool_calls: tuple[ToolCall, ...] = () usage = None + # A finished Ollama generation always ends on a terminal chunk: + # done:true (with its done_reason - stop, length, ...), or the + # tool_calls chunk the loop below already treats as terminal. + # Same sentinel posture as AnthropicProvider.stream()'s + # message_stop. + saw_done = False # ollama types its streaming chat() as `Iterator[ChatResponse]`, # which has no close() - but the object it actually returns is the # generator ollama/_client.py's own `_request` builds around @@ -256,6 +262,7 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider # inside it - `done` may not even be set the same chunk. if part["message"].get("tool_calls"): tool_calls = self._extract_tool_calls(part["message"]) + saw_done = True # review-fix: a tool-calling chunk IS the terminal # chunk (this comment block's own opening line) and # Ollama marks it done with real token counts, same @@ -270,6 +277,7 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider ) break if part.get("done"): + saw_done = True # ADR-006 stage 6.8: the terminal chunk carries # Ollama's real token counts. usage = normalize_usage( @@ -279,6 +287,19 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider finally: stream.close() # idempotent on an already-exhausted generator + # An NDJSON stream that just stops without a terminal chunk (a + # reverse proxy in front of a remote OLLAMA_HOST, a connection + # closed gracefully mid-answer) delivered a fragment - returning + # it as done would commit half a reply as the finished turn. + # Raised, never `continue`d: the reasoning retry below is for a + # model that FINISHED without an answer, and a truncation must + # reach the dispatch layer so it lands the streamed partial + # flagged incomplete. + if not saw_done: + raise RuntimeError( + "Ollama stream ended unexpectedly before completion. Please try again." + ) + if tool_calls: # ADR-007 stage 7.1: a pure tool-call turn is a legitimate, # complete outcome - it must NOT enter the reasoning-retry diff --git a/backend/providers/openai_provider.py b/backend/providers/openai_provider.py index 6a29a3f6..6326ec25 100644 --- a/backend/providers/openai_provider.py +++ b/backend/providers/openai_provider.py @@ -293,6 +293,14 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider **kwargs, ) usage = None + # A finished OpenAI-compatible generation always reports why it + # stopped: a non-null choices[].finish_reason (stop, length, + # tool_calls, content_filter, function_call) on its last content + # chunk - which is NOT necessarily the last chunk, since + # include_usage appends a usage-only chunk with empty choices after + # it, so this records the marker rather than stopping on it. Same + # sentinel posture as AnthropicProvider.stream()'s message_stop. + saw_finish_reason = False try: for chunk in stream: if cancel.is_set(): @@ -313,6 +321,10 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider choices = getattr(chunk, "choices", None) or [] if not choices: continue + # Read before the delta-is-None skip below: the finishing + # chunk's delta is often empty. + if any(getattr(choice, "finish_reason", None) for choice in choices): + saw_finish_reason = True delta = choices[0].delta if delta is None: continue @@ -348,6 +360,17 @@ def stream(self, request: ChatRequest, cancel: CancelToken) -> Iterator[Provider finally: stream.close() # idempotent on an already-closed/exhausted stream + # An iterator that just stops without a finish_reason (proxy + # truncation, a connection closed gracefully mid-answer) delivered a + # fragment - returning it as done would commit half a reply, or a + # tool call built from torn arguments, as the finished turn. Raising + # lets the dispatch layer land the streamed partial flagged + # incomplete instead. + if not saw_finish_reason: + raise RuntimeError( + "OpenAI-compatible stream ended unexpectedly before completion. Please try again." + ) + if tool_call_buffers: # ADR-007 stage 7.1: mirrors OllamaProvider.stream()'s own # tool_calls branch - a pure tool-call turn is a complete, diff --git a/backend/tests/test_ollama_stream_completion.py b/backend/tests/test_ollama_stream_completion.py new file mode 100644 index 00000000..821b6be5 --- /dev/null +++ b/backend/tests/test_ollama_stream_completion.py @@ -0,0 +1,82 @@ +"""OllamaProvider.stream() commits a reply only when the server said it +finished: a done:true chunk, or the tool_calls chunk the stream loop treats +as terminal. A stream that just stops is a truncation and must raise the +same unexpected-end error AnthropicProvider/GeminiProvider/OpenAIProvider +raise, so the dispatch layer lands the partial flagged incomplete.""" + +from __future__ import annotations + +import ollama +import pytest + +import graphlink_task_config as config +from backend.providers import CancelToken, ChatRequest, OllamaProvider + +_TRUNCATED = "Ollama stream ended unexpectedly before completion" + + +def _part(content="", *, thinking=None, tool_calls=None, done=False, done_reason=None): + message: dict = {"role": "assistant", "content": content} + if thinking is not None: + message["thinking"] = thinking + if tool_calls is not None: + message["tool_calls"] = tool_calls + part: dict = {"model": "llama3", "message": message, "done": done} + if done: + part.update(done_reason=done_reason or "stop", prompt_eval_count=9, eval_count=4) + return part + + +def _events(monkeypatch, *streams, model="llama3", tools=(), calls=None): + # `calls` is filled even when stream() raises, so a test can still + # count how many requests went out before the error. + calls = [] if calls is None else calls + + def fake_chat(**kwargs): + calls.append(kwargs) + parts = streams[len(calls) - 1] + + def generate(): + yield from parts + + return generate() + + monkeypatch.setattr(ollama, "chat", fake_chat) + provider = OllamaProvider(model=model) + request = ChatRequest(task=config.TASK_CHAT, messages=[{"role": "user", "content": "hi"}], tools=tools) + return list(provider.stream(request, CancelToken())), calls + + +def test_truncated_text_stream_raises_instead_of_committing_the_fragment(monkeypatch): + with pytest.raises(RuntimeError, match=_TRUNCATED): + _events(monkeypatch, [_part("The answer is"), _part(" half a sen")]) + + +def test_truncated_thinking_only_stream_raises_rather_than_retrying(monkeypatch): + # A reasoning model cut off mid-thought is a truncation, not a model + # that finished without an answer - so no reasoning retry goes out. + calls: list = [] + with pytest.raises(RuntimeError, match=_TRUNCATED): + _events( + monkeypatch, [_part(thinking="Let me think")], [_part("x", done=True)], + model="qwen3:8b", calls=calls, + ) + assert len(calls) == 1 + + +def test_complete_text_stream_passes(monkeypatch): + events, _ = _events(monkeypatch, [_part("Hello"), _part(" world"), _part("", done=True)]) + assert [e.type for e in events] == ["text", "text", "done"] + assert events[-1].text == "Hello world" + assert events[-1].usage == {"prompt_tokens": 9, "completion_tokens": 4} + + +def test_tool_call_only_stream_passes(monkeypatch): + call = {"function": {"name": "get_weather", "arguments": {"city": "Paris"}}} + events, _ = _events(monkeypatch, [_part(tool_calls=[call]), _part("", done=True)]) + assert [e.type for e in events] == ["tool_call", "done"] + + +def test_length_finish_passes(monkeypatch): + events, _ = _events(monkeypatch, [_part("A long answer"), _part(" that", done=True, done_reason="length")]) + assert events[-1].text == "A long answer that" diff --git a/backend/tests/test_provider_stream_completion.py b/backend/tests/test_provider_stream_completion.py new file mode 100644 index 00000000..3569f5d5 --- /dev/null +++ b/backend/tests/test_provider_stream_completion.py @@ -0,0 +1,335 @@ +"""A streamed reply is complete only when the server SAID it finished. + +AnthropicProvider.stream() refuses a stream that ends without message_stop +(proxy truncation, a gracefully closed connection mid-answer), and the +dispatch layer then lands the streamed partial flagged response_incomplete +instead of committing it as the finished reply. These tests pin the same +posture for the two providers whose streams carry their own finish marker: + +- Gemini: every finished candidate carries candidates[].finishReason (STOP, + MAX_TOKENS, SAFETY, ... - a function-call turn finishes with STOP too). +- OpenAI-compatible: the last content chunk carries choices[].finish_reason + (stop, length, tool_calls, content_filter, function_call); with + stream_options.include_usage a usage-only chunk with EMPTY choices + follows it, so the marker is not necessarily on the final chunk. + +The OpenAI fixtures are real openai SDK ChatCompletionChunk objects, and +the Gemini fixtures are raw `data:` lines run through the real +_gemini_stream_sse transport, so a normal complete stream here has exactly +the shape the production parsers see. +""" + +from __future__ import annotations + +import json +import types + +import pytest +from openai.types.chat import ChatCompletionChunk + +import api_provider +import graphlink_task_config as config +from backend.providers import CancelToken, ChatRequest, GeminiProvider, OpenAIProvider +from backend.providers.base import ToolSpec + +# Captured at import time, before the autouse conftest fixture swaps +# api_provider.chat_stream for its one-chunk stub - see test_providers.py's +# own identical pattern/comment. +_REAL_CHAT_STREAM = api_provider.chat_stream + +_TRUNCATED = "ended unexpectedly before completion" +_WEATHER_TOOL = ToolSpec( + name="get_weather", + description="Look up the weather.", + input_schema={"type": "object", "properties": {"city": {"type": "string"}}}, +) + + +def _request(*, tools=()): + return ChatRequest(task=config.TASK_CHAT, messages=[{"role": "user", "content": "hi"}], tools=tools) + + +# -- OpenAI-compatible -------------------------------------------------------- + + +def _openai_chunk(delta=None, finish_reason=None): + return ChatCompletionChunk.model_validate({ + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o", + "choices": [{"index": 0, "delta": delta if delta is not None else {}, "finish_reason": finish_reason}], + "usage": None, + }) + + +def _openai_usage_chunk(prompt_tokens=12, completion_tokens=5): + # stream_options.include_usage's trailing chunk: EMPTY choices, real usage. + return ChatCompletionChunk.model_validate({ + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o", + "choices": [], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + }) + + +def _openai_text_stream(*texts, finish_reason="stop", usage=True): + chunks = [_openai_chunk({"role": "assistant", "content": ""})] + chunks += [_openai_chunk({"content": text}) for text in texts] + if finish_reason is not None: + chunks.append(_openai_chunk({}, finish_reason=finish_reason)) + if usage: + chunks.append(_openai_usage_chunk()) + return chunks + + +def _openai_tool_call_stream(*argument_fragments, finish_reason="tool_calls"): + chunks = [_openai_chunk({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "index": 0, "id": "call_abc", "type": "function", + "function": {"name": "get_weather", "arguments": ""}, + }], + })] + chunks += [ + _openai_chunk({"tool_calls": [{"index": 0, "function": {"arguments": fragment}}]}) + for fragment in argument_fragments + ] + if finish_reason is not None: + chunks.append(_openai_chunk({}, finish_reason=finish_reason)) + chunks.append(_openai_usage_chunk()) + return chunks + + +class _FakeOpenAIStream: + """The SDK Stream's iterate-and-close() surface, nothing more.""" + + def __init__(self, chunks): + self._chunks = iter(chunks) + self.closed = False + + def __iter__(self): + return self + + def __next__(self): + return next(self._chunks) + + def close(self): + self.closed = True + + +def _openai_client(chunks): + def create(**kwargs): + assert kwargs.get("stream") is True + return _FakeOpenAIStream(chunks) + + return types.SimpleNamespace(chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))) + + +def _openai_events(chunks, *, tools=()): + provider = OpenAIProvider(client=_openai_client(chunks), model="gpt-4o") + return list(provider.stream(_request(tools=tools), CancelToken())) + + +def test_openai_truncated_text_stream_raises_instead_of_committing_the_fragment(): + with pytest.raises(RuntimeError, match=_TRUNCATED): + _openai_events(_openai_text_stream("The answer is", " half a sen", finish_reason=None)) + + +def test_openai_truncated_tool_call_stream_raises_the_same_error(): + # Cut off mid-arguments: the sentinel, not a JSON parse error on the + # torn fragment (or worse, a tool call built from partial arguments). + with pytest.raises(RuntimeError, match=_TRUNCATED): + _openai_events(_openai_tool_call_stream('{"ci', 'ty": "Par', finish_reason=None), tools=(_WEATHER_TOOL,)) + + +def test_openai_complete_text_stream_with_a_trailing_usage_chunk_passes(): + events = _openai_events(_openai_text_stream("Hello", " world")) + + assert [e.type for e in events] == ["text", "text", "done"] + assert events[-1].text == "Hello world" + assert events[-1].usage == {"prompt_tokens": 12, "completion_tokens": 5} + + +def test_openai_complete_stream_from_a_server_that_rejects_stream_options_passes(): + """A server that rejects stream_options gets the request retried + without it and never sends the trailing usage chunk; the finish_reason + chunk alone marks that stream complete.""" + calls = [] + + def create(**kwargs): + calls.append(kwargs) + if "stream_options" in kwargs: + raise TypeError("create() got an unexpected keyword argument 'stream_options'") + return _FakeOpenAIStream(_openai_text_stream("Hello", usage=False)) + + client = types.SimpleNamespace(chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=create))) + events = list(OpenAIProvider(client=client, model="gpt-4o").stream(_request(), CancelToken())) + + assert [("stream_options" in call) for call in calls] == [True, False] + assert events[-1].type == "done" and events[-1].text == "Hello" + assert events[-1].usage is None + + +def test_openai_finish_reason_on_the_last_content_chunk_itself_passes(): + # Several OpenAI-compatible servers put finish_reason on the chunk that + # carries the final content delta rather than on a separate empty one. + chunks = [_openai_chunk({"content": "Hel"}), _openai_chunk({"content": "lo"}, finish_reason="stop")] + assert _openai_events(chunks)[-1].text == "Hello" + + +def test_openai_tool_call_only_stream_passes(): + events = _openai_events(_openai_tool_call_stream('{"ci', 'ty": "Paris"}'), tools=(_WEATHER_TOOL,)) + + assert [e.type for e in events] == ["tool_call", "done"] + assert events[0].tool_call.name == "get_weather" + assert events[0].tool_call.arguments == {"city": "Paris"} + + +def test_openai_length_finish_passes(): + # A max_tokens cut-off is a FINISHED generation the server reported - + # not a transport truncation. + events = _openai_events(_openai_text_stream("A long answer that", finish_reason="length")) + assert events[-1].text == "A long answer that" + + +@pytest.mark.parametrize("finish_reason", ["content_filter", "function_call"]) +def test_openai_every_other_finish_reason_counts_as_complete(finish_reason): + events = _openai_events(_openai_text_stream("text", finish_reason=finish_reason)) + assert events[-1].type == "done" + + +def test_openai_truncation_through_chat_stream_delivers_the_partial_then_raises(monkeypatch): + """The dispatch-level contract the incomplete-reply landing relies on: + the streamed text reached on_chunk, and chat_stream RAISES rather than + returning the fragment as the finished message.""" + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_OPENAI) + monkeypatch.setattr(api_provider, "API_KEY", "sk-fake") + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_CHAT, "gpt-4o") + monkeypatch.setattr( + api_provider, "API_CLIENT", _openai_client(_openai_text_stream("half a sen", finish_reason=None)), + ) + + chunks = [] + with pytest.raises(RuntimeError, match=_TRUNCATED): + _REAL_CHAT_STREAM( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], + lambda delta, reset: chunks.append((delta, reset)), + ) + assert chunks == [("half a sen", False)] + + +# -- Gemini ------------------------------------------------------------------- + + +def _gemini_frame(*parts, finish_reason=None, usage=None) -> dict: + candidate: dict = {"content": {"parts": list(parts), "role": "model"}, "index": 0} + if finish_reason is not None: + candidate["finishReason"] = finish_reason + frame: dict = {"candidates": [candidate], "modelVersion": "gemini-2.5-pro"} + if usage is not None: + frame["usageMetadata"] = usage + return frame + + +_GEMINI_USAGE = {"promptTokenCount": 8, "candidatesTokenCount": 4, "totalTokenCount": 12} + + +class _FakeSSEResponse: + """urllib's HTTPResponse as _gemini_stream_sse consumes it: byte lines + (alt=sse: `data: ` frames separated by blank lines) and close().""" + + def __init__(self, frames): + self._lines = [] + for frame in frames: + self._lines += [f"data: {json.dumps(frame)}\r\n".encode(), b"\r\n"] + self.closed = False + + def __iter__(self): + return iter(self._lines) + + def close(self): + self.closed = True + + +def _gemini_events(monkeypatch, frames, *, tools=()): + monkeypatch.setattr( + api_provider.urllib.request, "urlopen", lambda request, timeout=None: _FakeSSEResponse(frames), + ) + provider = GeminiProvider(api_key="k", model="gemini-2.5-pro") + return list(provider.stream(_request(tools=tools), CancelToken())) + + +def test_gemini_truncated_text_stream_raises_instead_of_committing_the_fragment(monkeypatch): + frames = [_gemini_frame({"text": "The answer is"}), _gemini_frame({"text": " half a sen"})] + with pytest.raises(RuntimeError, match=_TRUNCATED): + _gemini_events(monkeypatch, frames) + + +def test_gemini_complete_text_stream_passes(monkeypatch): + frames = [ + _gemini_frame({"text": "Hello"}, usage={"promptTokenCount": 8, "totalTokenCount": 8}), + _gemini_frame({"text": " world"}, finish_reason="STOP", usage=_GEMINI_USAGE), + ] + events = _gemini_events(monkeypatch, frames) + + assert [e.type for e in events] == ["text", "text", "done"] + assert events[-1].text == "Hello world" + assert events[-1].usage == {"prompt_tokens": 8, "completion_tokens": 4} + + +def test_gemini_finish_reason_on_a_trailing_frame_with_no_text_passes(monkeypatch): + frames = [_gemini_frame({"text": "Hello"}), _gemini_frame({"text": ""}, finish_reason="STOP")] + assert _gemini_events(monkeypatch, frames)[-1].text == "Hello" + + +def test_gemini_tool_call_only_stream_passes(monkeypatch): + # Gemini finishes a function-call turn with finishReason STOP. + frames = [_gemini_frame( + {"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}, + finish_reason="STOP", usage=_GEMINI_USAGE, + )] + events = _gemini_events(monkeypatch, frames, tools=(_WEATHER_TOOL,)) + + assert [e.type for e in events] == ["tool_call", "done"] + assert events[0].tool_call.arguments == {"city": "Paris"} + + +def test_gemini_max_tokens_finish_passes(monkeypatch): + frames = [_gemini_frame({"text": "A long answer"}), _gemini_frame({"text": " that"}, finish_reason="MAX_TOKENS")] + assert _gemini_events(monkeypatch, frames)[-1].text == "A long answer that" + + +def test_gemini_safety_finish_with_partial_text_counts_as_complete(monkeypatch): + # The server reported WHY it stopped; that is a finished generation, + # not a dropped connection. + frames = [_gemini_frame({"text": "Partial"}), _gemini_frame(finish_reason="SAFETY")] + assert _gemini_events(monkeypatch, frames)[-1].text == "Partial" + + +def test_gemini_truncation_through_chat_stream_delivers_the_partial_then_raises(monkeypatch): + monkeypatch.setattr(api_provider, "USE_API_MODE", True) + monkeypatch.setattr(api_provider, "API_PROVIDER_TYPE", config.API_PROVIDER_GEMINI) + monkeypatch.setattr(api_provider, "API_KEY", "k") + monkeypatch.setattr(api_provider, "API_CLIENT", {"provider": "gemini"}) + monkeypatch.setitem(api_provider.API_MODELS, config.TASK_CHAT, "gemini-2.5-pro") + monkeypatch.setattr( + api_provider.urllib.request, "urlopen", + lambda request, timeout=None: _FakeSSEResponse([_gemini_frame({"text": "half a sen"})]), + ) + + chunks = [] + with pytest.raises(RuntimeError, match=_TRUNCATED): + _REAL_CHAT_STREAM( + config.TASK_CHAT, [{"role": "user", "content": "hi"}], + lambda delta, reset: chunks.append((delta, reset)), + ) + assert chunks == [("half a sen", False)] diff --git a/backend/tests/test_providers.py b/backend/tests/test_providers.py index 3771e2b5..440fd7f8 100644 --- a/backend/tests/test_providers.py +++ b/backend/tests/test_providers.py @@ -1269,7 +1269,7 @@ def close(self): self.close_calls += 1 -def _openai_chunk(content=None, reasoning_content=None, choices_empty=False): +def _openai_chunk(content=None, reasoning_content=None, choices_empty=False, finish_reason=None): import types if choices_empty: @@ -1278,7 +1278,7 @@ def _openai_chunk(content=None, reasoning_content=None, choices_empty=False): if reasoning_content is not None: delta_fields["reasoning_content"] = reasoning_content return types.SimpleNamespace( - choices=[types.SimpleNamespace(delta=types.SimpleNamespace(**delta_fields), finish_reason=None)] + choices=[types.SimpleNamespace(delta=types.SimpleNamespace(**delta_fields), finish_reason=finish_reason)] ) @@ -1311,7 +1311,7 @@ def test_openai_stream_yields_incremental_deltas_reasoning_and_a_done_matching_c _openai_chunk(choices_empty=True), # usage-only chunk must be skipped _openai_chunk(reasoning_content="hmm "), # compatible-server thinking delta _openai_chunk(content="lo "), - _openai_chunk(content="world"), + _openai_chunk(content="world", finish_reason="stop"), ]) provider = OpenAIProvider(client=client, model="gpt-5", reasoning_level="high") events = list(provider.stream( @@ -1598,8 +1598,11 @@ def fake_urlopen(request, timeout=None): assert response2.closed is True -def _gemini_sse_payload(*parts): - return {"candidates": [{"content": {"parts": list(parts)}}]} +def _gemini_sse_payload(*parts, finish_reason=None): + candidate = {"content": {"parts": list(parts)}} + if finish_reason is not None: + candidate["finishReason"] = finish_reason + return {"candidates": [candidate]} def test_gemini_stream_maps_thought_parts_to_reasoning_and_keeps_concatenation_parity(monkeypatch): @@ -1611,7 +1614,7 @@ def fake_stream_sse(url, body, timeout=120, cancel_event=None, api_key=None): sse_calls.update(url=url, body=body, timeout=timeout) yield _gemini_sse_payload({"text": "pondering... ", "thought": True}) yield _gemini_sse_payload({"text": "Ans"}) - yield _gemini_sse_payload({"text": "wer."}) + yield _gemini_sse_payload({"text": "wer."}, finish_reason="STOP") monkeypatch.setattr( "backend.providers.gemini_provider._prepare_gemini_contents", @@ -2371,7 +2374,7 @@ def test_openai_stream_requests_include_usage_and_captures_the_usage_chunk(): choices=[], usage=types.SimpleNamespace(prompt_tokens=100, completion_tokens=25), ) - stream = FakeSDKStream([_openai_chunk(content="Hi"), usage_chunk]) + stream = FakeSDKStream([_openai_chunk(content="Hi", finish_reason="stop"), usage_chunk]) client, stream, captured = _fake_openai_streaming_client(stream) from backend.providers import OpenAIProvider @@ -2389,7 +2392,7 @@ def test_openai_stream_retries_once_without_stream_options_when_the_server_rejec import types calls = [] - stream = FakeSDKStream([_openai_chunk(content="Hi")]) + stream = FakeSDKStream([_openai_chunk(content="Hi", finish_reason="stop")]) def create(**kwargs): calls.append(kwargs) @@ -2499,7 +2502,7 @@ def test_gemini_stream_captures_usage_metadata_from_the_trailing_frame(monkeypat def fake_stream_sse(url, body, timeout=120, cancel_event=None, api_key=None): yield _gemini_sse_payload({"text": "Answer."}) yield { - "candidates": [{"content": {"parts": []}}], + "candidates": [{"content": {"parts": []}, "finishReason": "STOP"}], "usageMetadata": {"promptTokenCount": 40, "candidatesTokenCount": 8}, } diff --git a/backend/tests/test_tool_calling.py b/backend/tests/test_tool_calling.py index 98a90e4f..bf3aa4c5 100644 --- a/backend/tests/test_tool_calling.py +++ b/backend/tests/test_tool_calling.py @@ -257,7 +257,7 @@ def close(self): self.close_calls += 1 -def _openai_chunk(content=None, tool_calls=None): +def _openai_chunk(content=None, tool_calls=None, finish_reason=None): """tool_calls: list of {"index", "id"?, "name"?, "arguments"?} - id/name omitted on continuation deltas, matching the real SDK only sending them on a call's first delta.""" @@ -273,7 +273,7 @@ def _openai_chunk(content=None, tool_calls=None): ] return types.SimpleNamespace( usage=None, - choices=[types.SimpleNamespace(delta=types.SimpleNamespace(**delta_fields))], + choices=[types.SimpleNamespace(delta=types.SimpleNamespace(**delta_fields), finish_reason=finish_reason)], ) @@ -299,7 +299,7 @@ def test_openai_capabilities_tools_is_true_unconditionally(): def test_openai_stream_translates_tools_into_the_native_function_shape(): - client, calls = _fake_openai_client([[_openai_chunk(content="hi")]]) + client, calls = _fake_openai_client([[_openai_chunk(content="hi", finish_reason="stop")]]) provider = OpenAIProvider(client=client, model="gpt-5") list(provider.stream( ChatRequest(task=config.TASK_CHAT, messages=[], tools=(ECHO_TOOL,)), CancelToken(), @@ -324,9 +324,9 @@ def test_openai_round_trips_an_echo_tool_call(): # JSON buffer, not just a single-chunk pass-through. id/name # only appear on the call's first delta, per the real wire shape. _openai_chunk(tool_calls=[{"index": 0, "id": "call_abc", "name": "echo", "arguments": '{"message": "hel'}]), - _openai_chunk(tool_calls=[{"index": 0, "arguments": 'lo"}'}]), + _openai_chunk(tool_calls=[{"index": 0, "arguments": 'lo"}'}], finish_reason="tool_calls"), ], - [_openai_chunk(content="The echo said: hello")], + [_openai_chunk(content="The echo said: hello", finish_reason="stop")], ]) provider = OpenAIProvider(client=client, model="gpt-5") @@ -511,8 +511,11 @@ def test_anthropic_tool_call_turn_reports_usage(monkeypatch): # behavior against the PUBLIC REST contract, not an installed library's types. -def _gemini_sse_payload(*parts): - return {"candidates": [{"content": {"parts": list(parts)}}]} +def _gemini_sse_payload(*parts, finish_reason=None): + candidate = {"content": {"parts": list(parts)}} + if finish_reason is not None: + candidate["finishReason"] = finish_reason + return {"candidates": [candidate]} def test_gemini_capabilities_tools_is_true_unconditionally(): @@ -524,7 +527,7 @@ def test_gemini_stream_translates_tools_into_the_native_function_declarations_sh def fake_stream_sse(url, body, timeout=120, cancel_event=None, api_key=None): sse_calls.update(body=body) - yield _gemini_sse_payload({"text": "hi"}) + yield _gemini_sse_payload({"text": "hi"}, finish_reason="STOP") monkeypatch.setattr("backend.providers.gemini_provider._gemini_stream_sse", fake_stream_sse) provider = GeminiProvider(api_key="k", model="gemini-2.5-pro") @@ -550,7 +553,7 @@ def fake_stream_sse(url, body, timeout=120, cancel_event=None, api_key=None): yield { "candidates": [{"content": {"parts": [ {"functionCall": {"name": "echo", "args": {"message": "hi"}}}, - ]}}], + ]}, "finishReason": "STOP"}], "usageMetadata": {"promptTokenCount": 22, "candidatesTokenCount": 6}, } @@ -575,9 +578,9 @@ def fake_stream_sse(url, body, timeout=120, cancel_event=None, api_key=None): # incremental args accumulation, unlike OpenAI/Anthropic. yield {"candidates": [{"content": {"parts": [ {"functionCall": {"name": "echo", "args": {"message": "hello"}}}, - ]}}]} + ]}, "finishReason": "STOP"}]} else: - yield _gemini_sse_payload({"text": "The echo said: hello"}) + yield _gemini_sse_payload({"text": "The echo said: hello"}, finish_reason="STOP") monkeypatch.setattr("backend.providers.gemini_provider._gemini_stream_sse", fake_stream_sse) provider = GeminiProvider(api_key="k", model="gemini-2.5-pro") From dc5f9091223a2fa9558f3cc41215d3939a0f8cb1 Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:29 -0400 Subject: [PATCH 05/11] Budget images at their vision-token cost Token counting replaced every image's bytes with a placeholder, so a 1.2 MB image was budgeted at 48 tokens against roughly 1,600 real ones. trim_history is the chat's context gate, so a few images on a small local window silently overflowed it. graphlink_memory.message_tokens now prices each image_bytes part with estimate_image_tokens, which reads the image header for its dimensions and applies Anthropic's documented vision formula (with a conservative fallback when the header is unreadable), and keeps the payload out of the text count. trim_history's returned context_tokens is now exactly the sum over the messages it returns; it used to keep counting a popped leading assistant turn and could count the newest message twice. Two consequences of dropping images sooner are handled here: the summary cache keys an image by digest instead of by the repr of its bytes (11 MB per image), and the context meter adds the same per-image figure so it shows what the budget spends. Co-Authored-By: Claude --- backend/canvas.py | 18 +++ backend/tests/test_context_budget.py | 181 +++++++++++++++++++++++++++ backend/token_counter.py | 8 +- graphlink_chat_agent.py | 22 +++- graphlink_memory.py | 57 +++++++-- graphlink_prompts.py | 4 + graphlink_token_estimator.py | 66 ++++++++++ 7 files changed, 340 insertions(+), 16 deletions(-) diff --git a/backend/canvas.py b/backend/canvas.py index 82a78910..86b0f6ac 100644 --- a/backend/canvas.py +++ b/backend/canvas.py @@ -164,6 +164,24 @@ def _history_token_text(history: list[dict]) -> str: return "\n\n".join(text for text in (_history_turn_text(turn) for turn in history) if text) +def _history_image_tokens(history: list[dict]) -> int: + """The vision-token cost of every image_bytes part in a + chat_branch_history - the half of the context _history_token_text + leaves out (an image has no text to tokenize). Passed alongside it to + set_context_text so the meter and graphlink_memory.trim_history's + budget price images the same way.""" + from graphlink_token_estimator import estimate_image_tokens + + total = 0 + for turn in history: + content = turn.get("content") + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "image_bytes": + total += estimate_image_tokens(part.get("data")) + return total + + def _chart_source_text(branch_history: list[dict]) -> str: """R6.2: flattens chat_branch_history's own {"role","content"} list (the SAME branch walk web_research/code_sandbox/gitlink already reuse via diff --git a/backend/tests/test_context_budget.py b/backend/tests/test_context_budget.py index 01648319..f30e70a7 100644 --- a/backend/tests/test_context_budget.py +++ b/backend/tests/test_context_budget.py @@ -412,3 +412,184 @@ def test_oversized_newest_message_is_kept_and_excluded_from_the_summarizer(monke assert len(captured["summarize_calls"]) == 1 summarizer_input = captured["summarize_calls"][0][1]["content"] assert "THE ACTUAL QUESTION" not in summarizer_input + + +# -- image vision-token budgeting + exact context_tokens --------------------- + + +def _png_bytes(width: int, height: int, *, noisy: bool = False) -> bytes: + """A real PNG of the given dimensions. `noisy` fills it with random + pixels so the encoded payload is large (an incompressible image is + what makes a payload-as-text count blow up).""" + import io + import os + + from PIL import Image + + if noisy: + image = Image.frombytes("RGB", (width, height), os.urandom(width * height * 3)) + else: + image = Image.new("RGB", (width, height), (120, 30, 200)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def _image_message(data, text: str = "what is in this picture?", role: str = "user") -> dict: + return {"role": role, "content": [ + {"type": "text", "text": text}, + {"type": "image_bytes", "data": data}, + ]} + + +def _text_tokens(message: dict) -> int: + import json + + return TokenEstimator().count_tokens(json.dumps(message)) + + +def test_estimate_image_tokens_follows_the_documented_vision_formula(): + import graphlink_token_estimator as estimator_module + + # Within every limit: width * height / 750, rounded up. + assert estimator_module.estimate_image_tokens(_png_bytes(1000, 800)) == 1067 + # Long edge over 1568px: scaled to a 1568px long edge first + # (2000x500 -> 1568x392 = 614,656 px -> 820 tokens). + assert estimator_module.estimate_image_tokens(_png_bytes(2000, 500)) == 820 + # Over the per-image token ceiling after the long-edge scale: capped. + assert ( + estimator_module.estimate_image_tokens(_png_bytes(1600, 1600)) + == estimator_module.IMAGE_TOKENS_MAX + ) + + +def test_estimate_image_tokens_falls_back_to_the_conservative_constant(): + import graphlink_token_estimator as estimator_module + + fallback = estimator_module.IMAGE_TOKENS_FALLBACK + # Never under-budget an image whose size cannot be read: the fallback + # is the most any single image can cost under the formula. + assert fallback == estimator_module.IMAGE_TOKENS_MAX + assert estimator_module.estimate_image_tokens(b"definitely not an image") == fallback + assert estimator_module.estimate_image_tokens(b"") == fallback + assert estimator_module.estimate_image_tokens(None) == fallback + + +def test_trim_history_budgets_an_image_at_its_vision_cost_not_a_placeholder(): + message = _image_message(_png_bytes(1000, 800)) + trimmed, tokens = trim_history([message], TokenEstimator(), max_tokens=1_000_000) + assert trimmed == [message] + # ~1,067 vision tokens plus a little text - the old placeholder-only + # count budgeted this at a few dozen. + assert 1067 <= tokens <= 1067 + 60 + + +def test_trim_history_excludes_a_base64_image_payload_from_the_text_count(): + import base64 + + # The provider transports also accept image_bytes data as base64 text; + # that payload is pixels, not prose, and must cost its vision estimate + # (400x300 -> 160 tokens), never ~1 token per 4 characters of base64. + encoded = base64.b64encode(_png_bytes(400, 300, noisy=True)).decode("ascii") + assert len(encoded) > 100_000 + trimmed, tokens = trim_history( + [_image_message(encoded)], TokenEstimator(), max_tokens=10_000_000 + ) + assert len(trimmed) == 1 + assert 160 <= tokens <= 160 + 60 + + +def test_trim_history_unreadable_image_costs_the_conservative_fallback(): + import graphlink_token_estimator as estimator_module + + message = _image_message(b"\x89PNG but truncated garbage") + _, tokens = trim_history([message], TokenEstimator(), max_tokens=1_000_000) + fallback = estimator_module.IMAGE_TOKENS_FALLBACK + assert fallback <= tokens <= fallback + 60 + + +def test_trim_history_trims_an_image_heavy_history_to_fit_a_small_budget(): + image = _png_bytes(1000, 800) # ~1,067 vision tokens each + history = [ + _image_message(image, text=f"picture {i}", role="user" if i % 2 == 0 else "assistant") + for i in range(8) + ] + trimmed, tokens = trim_history( + history, TokenEstimator(), max_tokens=4_000, system_prompt_estimate=0 + ) + # Eight images (~8.5k tokens) cannot fit 4k: only a contiguous newest + # suffix survives, and what it reports is genuinely within budget. + assert 0 < len(trimmed) < len(history) + assert trimmed == history[-len(trimmed):] + assert tokens <= 4_000 + assert tokens >= 1067 * len(trimmed) + + +def test_small_window_chat_worker_drops_images_that_would_overflow(monkeypatch): + # The production gate: an 8k-window local model's history budget is + # window - reserve (6,000 here). Eight ~1,067-token images must not all + # reach the wire. + captured = {} + monkeypatch.setattr(api_provider, "chat", _fake_chat_capturing(captured)) + image = _png_bytes(1000, 800) + history = [_image_message(image, text=f"picture {i}") for i in range(8)] + small_runtime = types.SimpleNamespace(context_window=lambda task: 8_000) + + worker = ChatWorker("") + worker.run(history, None, resolved_system_prompt="", runtime=small_runtime) + + sent_images = [ + part + for message in captured["messages"] + if isinstance(message.get("content"), list) + for part in message["content"] + if part.get("type") == "image_bytes" + ] + assert 0 < len(sent_images) <= 6_000 // 1067 + + +def test_trim_history_single_assistant_message_reports_its_own_tokens_once(): + message = {"role": "assistant", "content": "a lone assistant turn " + "x" * 160} + trimmed, tokens = trim_history([message], TokenEstimator(), max_tokens=1_000_000) + # Popped as a leading assistant, then restored by the keep-newest + # guarantee - counted exactly once, not once per pass. + assert trimmed == [message] + assert tokens == _text_tokens(message) + + +def test_trim_history_popped_leading_assistant_is_not_counted(): + assistant = {"role": "assistant", "content": "an earlier answer " + "a" * 200} + user = {"role": "user", "content": "the newest question " + "q" * 40} + trimmed, tokens = trim_history([assistant, user], TokenEstimator(), max_tokens=1_000_000) + assert trimmed == [user] + assert tokens == _text_tokens(user) + + +def test_summary_cache_key_digests_image_payloads_instead_of_stringifying_them(): + # Image turns are now dropped (and summarized) much sooner on small + # windows, so a key built from the payload's repr would grow with every + # dropped image - the digest keeps it small without merging two images. + from graphlink_chat_agent import _summary_cache_key + + big = _png_bytes(1200, 900, noisy=True) + other = _png_bytes(1200, 900) + key = _summary_cache_key([_image_message(big)]) + + assert len(str(key)) < 1_000 + assert key == _summary_cache_key([_image_message(big)]) + assert key != _summary_cache_key([_image_message(other)]) + assert _summary_cache_key([{"role": "user", "content": "plain"}]) == (("user", "plain"),) + + +def test_context_meter_prices_branch_images_like_the_budget_does(): + from backend.canvas import _history_image_tokens, _history_token_text + from backend.token_counter import TokenCounterState, estimate_tokens + from graphlink_token_estimator import estimate_image_tokens + + image = _png_bytes(800, 600) + history = [{"role": "user", "content": "hello"}, _image_message(image, text="look")] + state = TokenCounterState() + state.set_context_text(_history_token_text(history), image_tokens=_history_image_tokens(history)) + + assert _history_image_tokens(history) == estimate_image_tokens(image) > 100 + assert state.context_tokens == estimate_tokens(_history_token_text(history)) + estimate_image_tokens(image) diff --git a/backend/token_counter.py b/backend/token_counter.py index aa970831..a810042a 100644 --- a/backend/token_counter.py +++ b/backend/token_counter.py @@ -170,8 +170,12 @@ def set_input_text(self, text: str) -> None: def set_output_text(self, text: str) -> None: self.output_tokens = estimate_tokens(text) - def set_context_text(self, text: str) -> None: - self.context_tokens = estimate_tokens(text) + def set_context_text(self, text: str, image_tokens: int = 0) -> None: + """`image_tokens` is the branch's images, priced by + graphlink_token_estimator.estimate_image_tokens - the same figure + graphlink_memory.trim_history charges against the context budget, + so the meter shows what the budget is actually spending.""" + self.context_tokens = estimate_tokens(text) + max(0, int(image_tokens)) def reset_real_usage(self) -> None: """ADR-006 stage 6.8 review fix (stale real usage): called at diff --git a/graphlink_chat_agent.py b/graphlink_chat_agent.py index 729d9559..76018c29 100644 --- a/graphlink_chat_agent.py +++ b/graphlink_chat_agent.py @@ -26,6 +26,7 @@ independent reimplementation against SceneDocument, not this function. """ +import hashlib import json import logging from collections import OrderedDict @@ -52,7 +53,26 @@ def _summary_cache_key(messages) -> tuple: - return tuple((str(m.get("role")), str(m.get("content"))) for m in messages) + return tuple((str(m.get("role")), _content_cache_key(m.get("content"))) for m in messages) + + +def _content_cache_key(content) -> str: + """A message's content as a cache-key string, with each image_bytes + payload replaced by a digest. str() of the raw payload made the key as + large as the image (a 1.26 MB PNG gave a 3.6 MB key, ~15 ms to build) + for every dropped image on every send, and the cache keeps up to + _SUMMARY_CACHE_MAX_ENTRIES of them; a digest keeps the key small while two + different images still never share one.""" + if not isinstance(content, list): + return str(content) + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "image_bytes": + data = part.get("data") + payload = data if isinstance(data, (bytes, bytearray)) else str(data).encode("utf-8") + part = {**part, "data": hashlib.blake2b(payload, digest_size=16).hexdigest()} + parts.append(part) + return str(parts) def _longest_cached_prefix(key: tuple) -> tuple: diff --git a/graphlink_memory.py b/graphlink_memory.py index a65f9445..46ffc871 100644 --- a/graphlink_memory.py +++ b/graphlink_memory.py @@ -3,6 +3,7 @@ import re from graphlink_prompts import _TokenBytesEncoder +from graphlink_token_estimator import estimate_image_tokens def clone_history(history): @@ -28,20 +29,52 @@ def append_history(history, additions): return combined +def message_tokens(message, token_estimator): + """One history message's prompt-token estimate: its text (role, text + parts, structure) through the text tokenizer, plus each image_bytes + part's vision cost from graphlink_token_estimator.estimate_image_tokens. + + The image payload is taken OUT of the text view before tokenizing - its + bytes (or base64 text) say nothing about what the image costs a model, + which is a per-image figure derived from its dimensions. A placeholder- + only count budgets a full-size image at a few dozen tokens and lets an + image-heavy history overflow the window; counting its base64 as text + would over-budget it a hundredfold.""" + content = message.get("content") if isinstance(message, dict) else None + image_tokens = 0 + text_view = message + if isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "image_bytes": + image_tokens += estimate_image_tokens(part.get("data")) + part = {key: value for key, value in part.items() if key != "data"} + text_parts.append(part) + text_view = {**message, "content": text_parts} + text_tokens = token_estimator.count_tokens(json.dumps(text_view, cls=_TokenBytesEncoder)) + return text_tokens + image_tokens + + def trim_history(history, token_estimator, max_tokens=8000, system_prompt_estimate=500, reserve_tokens=0): normalized_history = clone_history(history) current_tokens = max(0, system_prompt_estimate) + max(0, reserve_tokens) - trimmed_history = [] + # Each kept message travels with its own token count, so every removal + # below (the leading-assistant pop) and every re-add (the keep-newest + # fallback) moves its tokens with it: the returned context_tokens is + # always exactly the sum over the messages actually returned - never a + # count that still includes a popped turn or counts the newest twice. + kept = [] for message in reversed(normalized_history): - message_tokens = token_estimator.count_tokens(json.dumps(message, cls=_TokenBytesEncoder)) - if current_tokens + message_tokens > max_tokens: + tokens = message_tokens(message, token_estimator) + if current_tokens + tokens > max_tokens: break - trimmed_history.insert(0, message) - current_tokens += message_tokens + kept.append((message, tokens)) + current_tokens += tokens + kept.reverse() - if trimmed_history and trimmed_history[0].get("role") == "assistant": - trimmed_history.pop(0) + if kept and kept[0][0].get("role") == "assistant": + kept.pop(0) # ADR-006 stage 6.8 review fix (keep-newest guarantee): the newest # message is ALWAYS kept, even when it alone exceeds the budget - the @@ -49,14 +82,12 @@ def trim_history(history, token_estimator, max_tokens=8000, system_prompt_estima # seeing the question at all (and beats it being replaced by a short # summary of itself). Applied AFTER the leading-assistant pop so a # single oversized newest message survives regardless of role. - if not trimmed_history and normalized_history: + if not kept and normalized_history: newest = normalized_history[-1] - trimmed_history = [newest] - current_tokens += token_estimator.count_tokens( - json.dumps(newest, cls=_TokenBytesEncoder) - ) + kept = [(newest, message_tokens(newest, token_estimator))] - context_tokens = max(0, current_tokens - system_prompt_estimate - reserve_tokens) + trimmed_history = [message for message, _ in kept] + context_tokens = sum(tokens for _, tokens in kept) return trimmed_history, context_tokens diff --git a/graphlink_prompts.py b/graphlink_prompts.py index f85bbaea..281ce667 100644 --- a/graphlink_prompts.py +++ b/graphlink_prompts.py @@ -2,6 +2,10 @@ import json from dataclasses import dataclass +# Keeps raw bytes out of a JSON-for-token-counting dump. This is NOT an +# image's budget: graphlink_memory.message_tokens strips image_bytes +# payloads before dumping and adds their vision-token estimate separately; +# this placeholder only backstops any other bytes value in a message. class _TokenBytesEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, bytes): diff --git a/graphlink_token_estimator.py b/graphlink_token_estimator.py index 3093e13d..956d4fa1 100644 --- a/graphlink_token_estimator.py +++ b/graphlink_token_estimator.py @@ -6,7 +6,11 @@ Nothing here has ever imported Qt. """ +import base64 +import binascii +import io import logging +import math try: import tiktoken @@ -16,6 +20,68 @@ logger = logging.getLogger(__name__) +# Vision-token cost of one image content part. A text tokenizer has nothing +# meaningful to say about an image: the pixels reach the model as a fixed +# per-image cost the provider derives from the image's DIMENSIONS, not from +# its encoded byte length. The formula is Anthropic's published one (Claude +# vision docs, "Evaluate image size" / "Calculate image costs"): an image is +# first scaled down, preserving aspect ratio, until its long edge is at most +# 1568px and it costs at most ~1,600 tokens (about 1.15-1.2 megapixels - +# 1092x1092 is the documented 1:1 maximum), then costs +# width * height / 750 tokens. It is the one shared estimate for every +# provider; the other backends' own tile/patch schemes are not modelled. +# For the hosted APIs it sits at or above their own figures, which is the +# right side to miss on: an over-estimate trims one turn early, an +# under-estimate overflows the window. A local vision model that keeps far +# more of a large image (Qwen2.5-VL's 28px patches, for one) can cost +# several times this, so there it is a floor rather than a ceiling. +IMAGE_MAX_LONG_EDGE_PX = 1568 +IMAGE_PIXELS_PER_TOKEN = 750 +IMAGE_TOKENS_MAX = 1600 +# Used whenever the dimensions cannot be read (a corrupt or unrecognised +# payload). Deliberately the formula's own ceiling, so an unreadable image +# is never budgeted BELOW what it might really cost. +IMAGE_TOKENS_FALLBACK = IMAGE_TOKENS_MAX + + +def _image_payload_bytes(data): + """The raw encoded image behind an image_bytes part's "data": the + runtime shape is raw bytes (attachments.py / content_codec's decode), + but the Anthropic transport also accepts a base64 string, so both are + read here. None when there is nothing decodable.""" + if isinstance(data, (bytes, bytearray, memoryview)): + return bytes(data) or None + if isinstance(data, str) and data: + try: + return base64.b64decode(data) or None + except (binascii.Error, ValueError): + return None + return None + + +def estimate_image_tokens(data) -> int: + """Vision-token estimate for one image payload (raw bytes or a base64 + string) - see the vision-token comment above IMAGE_MAX_LONG_EDGE_PX for + the formula and its source. Only the image HEADER is parsed (PIL's open() is lazy and never + decodes pixel data for .size), so this stays cheap on multi-megabyte + attachments; any failure to read dimensions degrades to the + conservative IMAGE_TOKENS_FALLBACK rather than raising.""" + raw = _image_payload_bytes(data) + if raw is None: + return IMAGE_TOKENS_FALLBACK + try: + from PIL import Image + + with Image.open(io.BytesIO(raw)) as image: + width, height = image.size + except Exception: + return IMAGE_TOKENS_FALLBACK + if width <= 0 or height <= 0: + return IMAGE_TOKENS_FALLBACK + long_edge_scale = min(1.0, IMAGE_MAX_LONG_EDGE_PX / max(width, height)) + pixels = (width * long_edge_scale) * (height * long_edge_scale) + return min(IMAGE_TOKENS_MAX, max(1, math.ceil(pixels / IMAGE_PIXELS_PER_TOKEN))) + class TokenEstimator: _instance = None From e56d97afaafbfeb99fa8634fb79a922e292760f5 Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:30 -0400 Subject: [PATCH 06/11] Keep the chat's continuation point through undo, redo, delete and reload last_chat_node_id decides where the next Send continues, but Commands only snapshotted nodes, edges, assets and pins: - Undoing a Send left the pointer on the removed node, so the next Send became a detached root with no parent edge. - Mark Final Deliverable changed nothing a Command could see, was dropped as a no-op, and Ctrl+Z undid the previous action instead. - removeNodes left both pointers naming deleted nodes. - The pointer was never saved, so the first Send after reopening a chat also started a new root. Commands now capture both document-level chat pointers before and after every mutator; undo restores them and redo re-applies the state undo left. remove_nodes moves the continuation point to the nearest surviving chat ancestor and clears a removed Final Deliverable mark, as delete_chat_node already did. The pointer is saved and restored like final_deliverable_node_id, and older saves load unchanged. Synthesizing branches now moves the pointer inside its own recorded command. Co-Authored-By: Claude --- backend/api/intents_branches.py | 14 +- backend/domain/_composed.py | 4 +- backend/domain/commands.py | 82 +++++- backend/domain/graph.py | 38 +++ backend/session_load.py | 12 + backend/session_save.py | 5 + backend/tests/test_chat_library.py | 23 ++ backend/tests/test_commands.py | 409 +++++++++++++++++++++++++++++ backend/tests/test_session_load.py | 45 ++++ backend/tests/test_session_save.py | 34 +++ 10 files changed, 655 insertions(+), 11 deletions(-) diff --git a/backend/api/intents_branches.py b/backend/api/intents_branches.py index 0a7c0390..2b20e30f 100644 --- a/backend/api/intents_branches.py +++ b/backend/api/intents_branches.py @@ -263,25 +263,25 @@ async def _on_success(text): # A source was deleted mid-flight - same liveness posture as # compare_branches's own on_success guard. return - # The synthesis node plus its synthesis metadata as one command. - # last_chat_node_id is deliberately set OUTSIDE it: that is - # document-level cursor state, not node state, so it is not - # something record_command's node/edge diff captures - restoring - # it correctly on undo is part of stage 10.2's stack work, where - # the cursor semantics actually live. + # The synthesis node, its synthesis metadata and the move of the + # continuation point onto it, as one command: record_command + # snapshots the document's chat pointers alongside the nodes and + # edges, so setting last_chat_node_id inside the mutator is what + # lets undo put it back on the previous tip and redo bring it + # forward to the synthesis node again. def _create_synthesis_node(): sx, sy = document.place_child(parent.id, "chat") created = document.add_chat_node(sx, sy, text, False, parent_id=parent.id) document.mark_branch_synthesis( created.id, ids, clean_instructions, route.get("provider"), route.get("modelLabel"), ) + document.last_chat_node_id = created.id return created node, _command = document.record_command( "synthesizeBranches", "agent", _create_synthesis_node, node_ids=[parent.id, *ids], ) - document.last_chat_node_id = node.id result_holder["node_id"] = node.id await bus.publish("scene") diff --git a/backend/domain/_composed.py b/backend/domain/_composed.py index 6f76d4af..b344f2f5 100644 --- a/backend/domain/_composed.py +++ b/backend/domain/_composed.py @@ -54,7 +54,9 @@ class SceneDocumentParts: # write. Without these BranchOps infers `str` from its own first # assignment and then rejects the `= None` two lines later, which # is how a field declared `str | None` on SceneDocument itself - # produced an error on SceneDocument's own declaration. + # produced an error on SceneDocument's own declaration. CommandOps + # snapshots and restores the two chat pointers through this same + # declaration (commands.py's ChatPointers). last_chat_node_id: str | None final_deliverable_node_id: str | None current_chat_id: int | None diff --git a/backend/domain/commands.py b/backend/domain/commands.py index ecf2fb78..abc03249 100644 --- a/backend/domain/commands.py +++ b/backend/domain/commands.py @@ -98,6 +98,39 @@ _AssetSnapshot = "tuple[bytes, str]" +@dataclass(frozen=True) +class ChatPointers: + """The document-level chat pointers, as one point-in-time value: + SceneDocument.last_chat_node_id (where the Composer's next Send + continues from) and final_deliverable_node_id (the one node marked + Final Deliverable). + + They are document state a recorded mutator changes - send_message + advances the first, set_final_deliverable rewrites the second, + delete_chat_node and remove_nodes re-point or clear both - but they + live on the document itself, outside nodes/edges/image_assets/pins, so + the per-id diff never sees them. Left out of a Command, undoing a Send would remove + its node while last_chat_node_id still named it - the next Send then + falls back to a detached root - and a Mark Final, which changes nothing + the diff can see, would be dropped as a no-op, so Ctrl+Z would undo + whatever came before it instead. + + Two strings, so captured WHOLE on every command rather than diffed per + field. Frozen, so a held reference is already a safe snapshot (the same + reasoning as NavigationPinRecord on Command.pin_before below).""" + + last_chat_node_id: str | None + final_deliverable_node_id: str | None + + @classmethod + def capture(cls, document: SceneDocumentParts) -> "ChatPointers": + return cls(document.last_chat_node_id, document.final_deliverable_node_id) + + def restore(self, document: SceneDocumentParts) -> None: + document.last_chat_node_id = self.last_chat_node_id + document.final_deliverable_node_id = self.final_deliverable_node_id + + @dataclass class Command: """A single invertible mutation, already applied by the time this @@ -136,6 +169,18 @@ class Command: # deleting the last pin - means "watched, this is what it was." pin_before: "tuple[NavigationPinRecord, ...] | None" = None pin_after: "tuple[NavigationPinRecord, ...] | None" = None + # The chat pointers as they stood immediately before and after the + # mutator ran. record_command ALWAYS fills both, changed or not - unlike + # pin_before/pin_after, which are set only when the store changed - so + # invert() puts the pointers back to exactly where they stood when this + # command began, even if something moved them after record_command + # returned (every pointer move in the app happens inside a mutator, but + # undo must not depend on that staying true). That is also why + # pointers_after is re-captured by invert() rather than trusted from + # record time - see there. None means "not captured" (a Command built by + # hand) and is never restored. + pointers_before: ChatPointers | None = None + pointers_after: ChatPointers | None = None # ADR-010 stage 10.5: the agent run this command belongs to, or None for # a direct user action. Set for every mutation an agent run produces, so # "undo this build" can revert a whole run's worth of commands as a unit @@ -163,9 +208,13 @@ def is_noop(self) -> bool: came out byte-identical to how it went in) - the idempotent- connect() case is the motivating example, but any accidental no-op call hits this the same way.""" - return not ( - self.node_before or self.node_after or self.edge_before or self.edge_after - ) and (self.pin_before is None or self.pin_before == self.pin_after) + return ( + not (self.node_before or self.node_after or self.edge_before or self.edge_after) + and (self.pin_before is None or self.pin_before == self.pin_after) + # Compared by value, not presence: both are always captured, so + # only an actual pointer move (Mark Final, a Send) counts. + and self.pointers_before == self.pointers_after + ) def invert(self, document: SceneDocumentParts) -> None: """Restores document state to exactly how it was before this @@ -184,6 +233,18 @@ def invert(self, document: SceneDocumentParts) -> None: # snapshotted as one unit (see record_command's own doc for why # per-id scoping doesn't apply to pins the way it does nodes). document.pins.reset(list(self.pin_before)) + if self.pointers_before is not None: + # Undo only ever runs this on the command at the top of the + # stack, i.e. with the document in this command's after-state - + # PLUS any pointer move made once record_command had returned. + # Re-capturing here folds such a late move into the after-state + # redo restores, so undo-then-redo is the identity on the + # pointers too: a redone reply puts the pointer back on the + # reply, never on the user's message before it, which would make + # the next Send fork a sibling of the reply and drop it from the + # history. + self.pointers_after = ChatPointers.capture(document) + self.pointers_before.restore(document) def apply(self, document: SceneDocumentParts) -> None: """The mirror of invert() - restores to the *_after state. Not @@ -195,6 +256,8 @@ def apply(self, document: SceneDocumentParts) -> None: _restore(document.image_assets, self.asset_after) if self.pin_after is not None: document.pins.reset(list(self.pin_after)) + if self.pointers_after is not None: + self.pointers_after.restore(document) def _restore(live: dict, snapshot: dict) -> None: @@ -425,6 +488,15 @@ def _merge_commands(command_type, provenance, commands, run_id=None): merged.pin_before = command.pin_before if command.pin_after is not None: merged.pin_after = command.pin_after + # Chat pointers follow the same first-before/last-after rule. They + # are deliberately NOT cleared below when they net out: equal + # values already make is_noop ignore them, and keeping them means + # undoing the group still returns the pointers to where they stood + # before it, whatever moved them in between. + if command.pointers_before is not None and merged.pointers_before is None: + merged.pointers_before = command.pointers_before + if command.pointers_after is not None: + merged.pointers_after = command.pointers_after # An id created and then deleted inside the same group (or restored to # exactly what it was) nets out to no change at all - dropping those @@ -551,6 +623,7 @@ def record_command( # forgotten - the same reasoning record_command's own doc gives for # rejecting a `pin_ids` parameter. pin_snapshot_before = self.pins.records + pointers_before = ChatPointers.capture(self) result = mutator() @@ -558,6 +631,7 @@ def record_command( edge_ids_after = set(self.edges.keys()) asset_ids_after = set(self.image_assets.keys()) pin_snapshot_after = self.pins.records + pointers_after = ChatPointers.capture(self) node_before_out: dict[str, "SceneNode | None"] = {} node_after_out: dict[str, "SceneNode | None"] = {} @@ -623,6 +697,8 @@ def record_command( asset_after=asset_after_out, pin_before=pin_before_out, pin_after=pin_after_out, + pointers_before=pointers_before, + pointers_after=pointers_after, run_id=run_id, ) # A no-op command (an idempotent connect() that created nothing, a diff --git a/backend/domain/graph.py b/backend/domain/graph.py index 810c9287..6a40ab33 100644 --- a/backend/domain/graph.py +++ b/backend/domain/graph.py @@ -158,6 +158,11 @@ class SceneDocument( # R3.3: which chat node the Composer's next Send continues from - the # Qt-free stand-in for "the currently active branch" until real node # selection exists. None means the next message starts a fresh root. + # Saved and restored with the chat (session_save/session_load), and + # captured by every undoable Command alongside final_deliverable_node_id + # below (ChatPointers, backend/domain/commands.py): losing it on a + # reload, or leaving it naming a node an undo removed, makes the next + # Send start a detached root instead of continuing the branch. last_chat_node_id: str | None = None # ADR-002 Workstream 1 ("Branch status and lifecycle"): the one node # currently marked as this document's Final Deliverable, or None if @@ -678,6 +683,35 @@ def move_nodes(self, positions: list[tuple[str, float, float]]) -> None: self._recompute_group_bounds(group_id) def remove_nodes(self, node_ids: list[str]) -> None: + # The chat pointers must not outlive their node either, the same + # rule delete_chat_node applies (branches.py): left naming a removed + # node, last_chat_node_id makes send_message fall back to a detached + # root, and final_deliverable_node_id marks nothing. The continuation + # point moves to the nearest surviving chat-kind ancestor - resolved + # HERE, before the loop below deletes the very parent edges the walk + # reads. Chat-kind only, the same rule session_load applies to a + # saved pointer, so the next Send always continues a conversation. + # The mark is cleared rather than moved, as in delete_chat_node: no + # ancestor was ever marked by the user. Undo restores both through + # the Command's ChatPointers snapshot (commands.py). + removing = {node_id for node_id in node_ids if node_id in self.nodes} + tip = self.last_chat_node_id + retargeted_tip = tip + if tip is not None and tip in removing: + retargeted_tip = None + visited = {tip} + parent_edge = self._branch_parent_edge(tip) + while parent_edge is not None and parent_edge.source not in visited: + ancestor_id = parent_edge.source + ancestor = self.nodes.get(ancestor_id) + if ancestor_id not in removing and ancestor is not None and ancestor.kind == "chat": + retargeted_tip = ancestor_id + break + # visited guards a pre-ADR-009 cycle connect_unchecked can + # restore on load - the walk must end, not spin on it. + visited.add(ancestor_id) + parent_edge = self._branch_parent_edge(ancestor_id) + for node_id in node_ids: node = self.nodes.pop(node_id, None) if node is not None: @@ -708,6 +742,10 @@ def remove_nodes(self, node_ids: list[str]) -> None: # clean up here. self._detach_node_from_membership(node_id) + self.last_chat_node_id = retargeted_tip + if self.final_deliverable_node_id is not None and self.final_deliverable_node_id in removing: + self.final_deliverable_node_id = None + # -- ADR-014 stage 14.1: Plugin SDK node-creation primitive ------------- def add_plugin_node( diff --git a/backend/session_load.py b/backend/session_load.py index b2180abb..9591df11 100644 --- a/backend/session_load.py +++ b/backend/session_load.py @@ -1947,3 +1947,15 @@ def _restore_chat_into_document( final_new_id = nodes_by_id[str(raw_final_id)] if kind_by_new_id.get(final_new_id) == "chat": document.final_deliverable_node_id = final_new_id + + # The Composer's continuation point, translated and validated exactly + # like final_deliverable_node_id above: a save written before this key + # existed, or one naming a missing/non-chat node, leaves it at + # clear_for_load's None - the next Send starts a fresh root, which is + # all any load did before - rather than pointing the Composer at a node + # that is not a chat message. + raw_last_chat_id = chat_data.get("last_chat_node_id") + if raw_last_chat_id and str(raw_last_chat_id) in nodes_by_id: + last_chat_new_id = nodes_by_id[str(raw_last_chat_id)] + if kind_by_new_id.get(last_chat_new_id) == "chat": + document.last_chat_node_id = last_chat_new_id diff --git a/backend/session_save.py b/backend/session_save.py index 40c6a3c4..2f59b580 100644 --- a/backend/session_save.py +++ b/backend/session_save.py @@ -1114,6 +1114,11 @@ def _build_chat_data( # SceneDocument.final_deliverable_node_id's own comment for why # this is not a per-node field. "final_deliverable_node_id": document.final_deliverable_node_id, + # The Composer's continuation point, the other document-level chat + # pointer, saved the same way. Without it a reopened chat has no + # active branch, so the first Send after a load starts a detached + # root instead of continuing the conversation. + "last_chat_node_id": document.last_chat_node_id, "notes_data": [_serialize_note(n) for n in notes], "pins_data": [_serialize_pin(r) for r in document.pins.records], # The 12 basic connection lists all draw from the SAME classified diff --git a/backend/tests/test_chat_library.py b/backend/tests/test_chat_library.py index ebe3dd6b..a83e9238 100644 --- a/backend/tests/test_chat_library.py +++ b/backend/tests/test_chat_library.py @@ -2166,6 +2166,29 @@ def test_loading_a_chat_seeds_the_save_state_so_the_next_tick_is_a_no_op(db_path assert writes == [], "a tick right after loadChat must not rewrite the row" +def test_reopening_a_saved_chat_continues_the_branch_from_its_saved_tip(db_path): + # The real Save -> Load path: before last_chat_node_id was persisted, + # the first Send after reopening any chat started a detached root. + seed_bus, seed_document, _ = _library_session(db_path) + # Advance the id counter so a pointer saved verbatim (untranslated) + # could not coincidentally name the right node in the loaded document. + for _ in range(3): + seed_document.remove_nodes([seed_document.add_node(0, 0).id]) + seed_document.send_message("first") + seed_document.send_message("second") + asyncio.run(seed_bus.dispatch_intent("app-chat-library", "saveChat", [])) + chat_id = seed_document.current_chat_id + + bus, document, _ = _library_session(db_path) + asyncio.run(bus.dispatch_intent("app-chat-library", "loadChat", [chat_id])) + + tip = next(n for n in document.nodes.values() if n.content == "second") + assert document.last_chat_node_id == tip.id + + third = document.send_message("third") + assert [e.source for e in document.edges.values() if e.target == third.id] == [tip.id] + + def test_deleting_the_open_chat_clears_the_pointer_and_reenables_autosave(db_path): # Audit finding (real bug): deleteChat left current_chat_id dangling and # left the content-only digest looking "already saved", so a user who diff --git a/backend/tests/test_commands.py b/backend/tests/test_commands.py index ea8fac85..453569df 100644 --- a/backend/tests/test_commands.py +++ b/backend/tests/test_commands.py @@ -1140,3 +1140,412 @@ def test_undo_never_resurrects_a_snapshotted_run_owned_harness_status(): assert document.nodes[node.id].state.harness_status == "interrupted", ( "redo must not resurrect it either" ) + + +# -- document-level chat pointers (last_chat_node_id / final_deliverable) ---- +# +# Both pointers live on SceneDocument itself, outside nodes/edges/assets/pins, +# so the per-id diff above never sees them change. These pin that a Command +# carries them anyway: undoing a Send must move the Composer's continuation +# point back with the node it removes, and a Mark Final that changes nothing +# BUT the pointer must still be a real, undoable command. + + +def _send(document, text): + node, _command = document.record_command( + "sendMessage", "user", lambda: document.send_message(text), + ) + return node + + +def _parent_ids(document, node_id): + return [e.source for e in document.edges.values() if e.target == node_id] + + +def test_undoing_a_send_moves_the_continuation_point_back_so_the_next_send_is_not_an_orphan(document): + first = _send(document, "first") + second = _send(document, "second") + assert document.last_chat_node_id == second.id + + document.undo() + assert second.id not in document.nodes + assert document.last_chat_node_id == first.id, ( + "undo removed the tip node, so the pointer must return to where it was before that send" + ) + + third = _send(document, "third") + assert _parent_ids(document, third.id) == [first.id], ( + "the next send must continue the branch, not start a detached root" + ) + + +def test_redoing_a_send_restores_the_continuation_point_too(document): + first = _send(document, "first") + second = _send(document, "second") + + document.undo() + assert document.last_chat_node_id == first.id + document.redo() + assert second.id in document.nodes + assert document.last_chat_node_id == second.id + + third = _send(document, "third") + assert _parent_ids(document, third.id) == [second.id] + + +def test_undoing_every_send_returns_the_pointer_to_none(document): + _send(document, "first") + document.undo() + assert document.nodes == {} + assert document.last_chat_node_id is None + + +def test_mark_final_deliverable_is_a_real_undoable_redoable_command(document): + node = _send(document, "the deliverable") + log_length = len(document.command_log) + + _result, command = document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(node.id, True), + ) + assert document.final_deliverable_node_id == node.id + assert not command.is_noop, "a pointer-only change is still a change" + assert len(document.command_log) == log_length + 1 + assert document.undo_label() == "Mark Final" + + document.undo() + assert document.final_deliverable_node_id is None + assert node.id in document.nodes, "Ctrl+Z must undo the Mark Final, not the send before it" + + document.redo() + assert document.final_deliverable_node_id == node.id + + +def test_moving_the_final_deliverable_mark_undoes_back_to_the_previous_holder(document): + first = _send(document, "first") + second = _send(document, "second") + document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(first.id, True), + ) + document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(second.id, True), + ) + assert document.final_deliverable_node_id == second.id + + document.undo() + assert document.final_deliverable_node_id == first.id + + +def test_re_marking_the_current_final_deliverable_is_still_a_noop(document): + node = _send(document, "the deliverable") + document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(node.id, True), + ) + log_length = len(document.command_log) + + _result, command = document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(node.id, True), + ) + assert command.is_noop + assert len(document.command_log) == log_length + + +def test_undoing_a_chat_node_delete_restores_both_pointers(document): + first = _send(document, "first") + second = _send(document, "second") + document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(second.id, True), + ) + + document.record_command( + "deleteChatNode", "user", lambda: document.delete_chat_node(second.id), + node_ids=[second.id], + ) + # delete_chat_node re-points the tip to the parent and clears the mark. + assert document.last_chat_node_id == first.id + assert document.final_deliverable_node_id is None + + document.undo() + assert second.id in document.nodes + assert document.last_chat_node_id == second.id + assert document.final_deliverable_node_id == second.id + + +def test_composite_restores_the_pointer_from_before_the_whole_group(document): + first = _send(document, "first") + with document.composite("twoSends", "user"): + _send(document, "second") + third = _send(document, "third") + + command = document.command_log[-1] + command.invert(document) + assert document.last_chat_node_id == first.id + command.apply(document) + assert document.last_chat_node_id == third.id + + +def test_a_composite_whose_pointer_change_nets_out_is_not_logged(document): + node = _send(document, "the deliverable") + log_length = len(document.command_log) + + with document.composite("markThenUnmark", "user"): + document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(node.id, True), + ) + document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(node.id, False), + ) + assert len(document.command_log) == log_length + + +def test_undoing_an_assistant_reply_returns_the_pointer_to_the_users_message(): + """The reply landing sets last_chat_node_id AFTER its chatReply command + has been recorded (backend/api/intents_chat.py's _on_reply), so the + reply command's own mutator never changed the pointer. Undoing that + reply must still put the pointer back where it stood before the reply - + otherwise it dangles at the node the undo just removed, and the next + Send becomes a detached root. The commonest real undo there is: send, + read the reply, Ctrl+Z it, send again.""" + import asyncio + from unittest.mock import patch + + import api_provider + import graphlink_task_config as task_config + from backend.tests.conftest import chat_slots + from backend.tests.test_canvas import make_bus_with_dispatcher + + async def run(): + bus, document, _recorder, dispatcher = make_bus_with_dispatcher() + + def fake_chat(task, messages, **kwargs): + return {"message": {"content": "an assistant reply"}} + + with patch.object(api_provider, "USE_API_MODE", False), \ + patch.object(api_provider, "LOCAL_PROVIDER_TYPE", task_config.LOCAL_PROVIDER_OLLAMA), \ + patch.dict(task_config.OLLAMA_MODELS, {task_config.TASK_CHAT: "test-model"}), \ + patch.object(api_provider, "chat", fake_chat): + user_id = await bus.dispatch_intent("scene", "sendMessage", ["first"]) + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + reply_id = document.last_chat_node_id + assert reply_id != user_id and reply_id in document.nodes + assert document.command_log[-1].command_type == "chatReply" + + await bus.dispatch_intent("scene", "undo", []) + assert reply_id not in document.nodes + assert document.last_chat_node_id == user_id + + next_node = document.send_message("second") + assert _parent_ids(document, next_node.id) == [user_id] + + asyncio.run(run()) + + +def test_set_final_deliverable_intent_is_undoable_end_to_end(wired): + bus, document = wired + node_id = _dispatch(bus, "addChatNode", 0, 0, "the deliverable", False, None) + log_length = len(document.command_log) + + _dispatch(bus, "setFinalDeliverable", node_id, True) + assert document.final_deliverable_node_id == node_id + assert len(document.command_log) == log_length + 1 + assert document.command_log[-1].label == "Mark Final" + + _dispatch(bus, "undo") + assert document.final_deliverable_node_id is None + assert node_id in document.nodes + + _dispatch(bus, "redo") + assert document.final_deliverable_node_id == node_id + + +def test_redoing_an_assistant_reply_puts_the_pointer_back_on_the_reply(): + """Undo then redo must leave the document exactly as it was, pointers + included. The reply's chatReply command is recorded BEFORE the pointer + moves onto the reply (intents_chat.py's _on_reply sets it once + record_command has returned), so a redo that trusted the pointer value + seen at record time would leave it on the user's message: the next + Send would then fork a sibling of the reply instead of continuing from + it, and the reply would drop out of that Send's history.""" + import asyncio + from unittest.mock import patch + + import api_provider + import graphlink_task_config as task_config + from backend.tests.conftest import chat_slots + from backend.tests.test_canvas import make_bus_with_dispatcher + + async def run(): + bus, document, _recorder, dispatcher = make_bus_with_dispatcher() + + def fake_chat(task, messages, **kwargs): + return {"message": {"content": "an assistant reply"}} + + with patch.object(api_provider, "USE_API_MODE", False), \ + patch.object(api_provider, "LOCAL_PROVIDER_TYPE", task_config.LOCAL_PROVIDER_OLLAMA), \ + patch.dict(task_config.OLLAMA_MODELS, {task_config.TASK_CHAT: "test-model"}), \ + patch.object(api_provider, "chat", fake_chat): + user_id = await bus.dispatch_intent("scene", "sendMessage", ["first"]) + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + reply_id = document.last_chat_node_id + assert reply_id != user_id and reply_id in document.nodes + + await bus.dispatch_intent("scene", "undo", []) + assert document.last_chat_node_id == user_id + await bus.dispatch_intent("scene", "redo", []) + assert reply_id in document.nodes + assert document.last_chat_node_id == reply_id, ( + "redo restored the reply, so the continuation point must be back on it" + ) + + next_node = document.send_message("second") + assert _parent_ids(document, next_node.id) == [reply_id] + + asyncio.run(run()) + + +def test_redoing_a_partial_reply_puts_the_pointer_back_on_it(document): + """The stream-died path lands its partial reply the same way as a full + one - command first, pointer second - so redo must restore the pointer + the reply left behind, not the one its command saw.""" + from backend.api.intents_chat import _land_partial_reply_node + + user = _send(document, "first") + _land_partial_reply_node(document, user, "half a rep") + partial_id = document.last_chat_node_id + assert partial_id != user.id + + document.undo() + assert partial_id not in document.nodes + assert document.last_chat_node_id == user.id + document.redo() + assert document.last_chat_node_id == partial_id + + after = _send(document, "second") + assert _parent_ids(document, after.id) == [partial_id] + + +def test_undo_then_redo_of_a_synthesis_restores_the_pointer_both_ways(monkeypatch): + """Synthesize Branches records its node and only then moves the pointer + onto it (intents_branches.py's on_success), the same shape as a reply + landing: undo must return the pointer to where it stood before, and + redo must put it back on the synthesis node.""" + import asyncio + + import backend.agents as agents_module + from backend.tests.conftest import drain_runs + from backend.tests.test_canvas import make_bus_with_dispatcher + + monkeypatch.setattr( + agents_module.BranchSynthesisAgent, "get_response", + lambda self, text, instructions: "Combined answer drawing on both branches.", + ) + + async def run(): + bus, document, _recorder, dispatcher = make_bus_with_dispatcher() + root = document.add_chat_node(0, 0, "root question", True) + first = document.add_chat_node(0, 160, "first answer", False, parent_id=root.id) + second = document.add_chat_node(460, 160, "second answer", False, parent_id=root.id) + document.last_chat_node_id = second.id + ids_before = set(document.nodes) + + await bus.dispatch_intent( + "scene", "synthesizeBranches", [[first.id, second.id], "merge the best of both"], + ) + await drain_runs(dispatcher, "branch_synthesis") + (synthesis_id,) = set(document.nodes) - ids_before + assert document.last_chat_node_id == synthesis_id + + await bus.dispatch_intent("scene", "undo", []) + assert synthesis_id not in document.nodes + assert document.last_chat_node_id == second.id + await bus.dispatch_intent("scene", "redo", []) + assert synthesis_id in document.nodes + assert document.last_chat_node_id == synthesis_id + + next_node = document.send_message("follow-up") + assert _parent_ids(document, next_node.id) == [synthesis_id] + + asyncio.run(run()) + + +def _remove(document, ids): + document.record_command( + "removeNodes", "user", lambda: document.remove_nodes(list(ids)), node_ids=list(ids), + ) + + +def test_removing_the_tip_moves_the_continuation_point_to_its_parent(document): + a = _send(document, "a") + b = _send(document, "b") + document.record_command( + "setFinalDeliverable", "user", lambda: document.set_final_deliverable(b.id, True), + ) + + _remove(document, [b.id]) + assert document.last_chat_node_id == a.id + assert document.final_deliverable_node_id is None + + c = _send(document, "c") + assert _parent_ids(document, c.id) == [a.id], ( + "the next send must continue from the removed tip's parent, not start a detached root" + ) + + document.undo() # the send of c + document.undo() # the removal + assert b.id in document.nodes + assert document.last_chat_node_id == b.id + assert document.final_deliverable_node_id == b.id + + +def test_removing_the_tip_and_its_parent_together_walks_up_past_both(document): + a = _send(document, "a") + b = _send(document, "b") + c = _send(document, "c") + + _remove(document, [c.id, b.id]) + assert document.last_chat_node_id == a.id + + +def test_removing_a_whole_branch_returns_the_pointer_to_none(document): + a = _send(document, "a") + b = _send(document, "b") + + _remove(document, [a.id, b.id]) + assert document.last_chat_node_id is None + + +def test_removing_a_node_off_the_tips_path_leaves_the_pointer_alone(document): + a = _send(document, "a") + b = _send(document, "b") + side = document.send_message("a sibling of b", branch_from_node_id=a.id) + document.last_chat_node_id = b.id + + _remove(document, [side.id]) + assert document.last_chat_node_id == b.id + + +def test_removing_the_tip_inside_a_legacy_cycle_terminates(document): + """connect_unchecked can restore a pre-ADR-009 cycle on load; the walk + up from a removed tip must stop rather than spin on it.""" + a = _send(document, "a") + b = _send(document, "b") + document.connect_unchecked(b.id, a.id) + + _remove(document, [a.id, b.id]) + assert document.last_chat_node_id is None + + +def test_remove_nodes_intent_moves_the_pointer_and_undo_restores_it(wired): + bus, document = wired + a = document.send_message("a") + b = document.send_message("b") + + _dispatch(bus, "removeNodes", [b.id]) + assert document.last_chat_node_id == a.id + + _dispatch(bus, "undo") + assert b.id in document.nodes + assert document.last_chat_node_id == b.id diff --git a/backend/tests/test_session_load.py b/backend/tests/test_session_load.py index 74c89520..1f9c108e 100644 --- a/backend/tests/test_session_load.py +++ b/backend/tests/test_session_load.py @@ -1024,6 +1024,51 @@ def test_restore_final_deliverable_node_id_rejects_a_resolved_non_chat_node(): assert document.final_deliverable_node_id is None +def test_restore_translates_last_chat_node_id(): + # Same never-colliding payload id as the final_deliverable test above. + document = _restore( + nodes=[ + _chat("legacy-uuid-root", raw_content="root"), + _chat("legacy-uuid-tip", raw_content="tip", parent_node_index=0), + ], + last_chat_node_id="legacy-uuid-tip", + ) + tip = next(n for n in document.nodes.values() if n.content == "tip") + assert document.last_chat_node_id == tip.id + assert tip.id != "legacy-uuid-tip" + + +def test_restore_without_a_last_chat_node_id_key_loads_exactly_as_before(): + # Every save written before the pointer was persisted - including the + # legacy app's - has no such key. + document = _restore(nodes=[_chat("a"), _chat("b")]) + assert document.last_chat_node_id is None + assert len(document.nodes) == 2 + + +def test_restore_last_chat_node_id_stale_reference_is_dropped(): + document = _restore(nodes=[_chat("n0")], last_chat_node_id="does-not-exist") + assert document.last_chat_node_id is None + + +def test_restore_last_chat_node_id_rejects_a_resolved_non_chat_node(): + document = _restore( + nodes=[ + _chat("parent"), + {"node_type": "code", "id": "code-1", "code": "print(1)", "language": "python", + "position": {"x": 0, "y": 0}, "parent_content_node_index": 0}, + ], + last_chat_node_id="code-1", + ) + assert document.last_chat_node_id is None + + +def test_restore_last_chat_node_id_of_the_wrong_json_shape_is_dropped_not_raised(): + document = _restore(nodes=[_chat("a")], last_chat_node_id=["not", "an", "id"]) + assert document.last_chat_node_id is None + assert len(document.nodes) == 1 + + # -- SECURITY-FIX: a persisted audio_file part is an arbitrary local path the # -- provider layer would read and upload; neutralized on load ---------------- diff --git a/backend/tests/test_session_save.py b/backend/tests/test_session_save.py index 16d824cb..fb3d8e3b 100644 --- a/backend/tests/test_session_save.py +++ b/backend/tests/test_session_save.py @@ -625,6 +625,40 @@ def test_final_deliverable_node_id_serializes_as_none_when_unmarked(): assert chat_data["final_deliverable_node_id"] is None +def _burn_ids(doc: SceneDocument, count: int = 3) -> None: + """Advances doc's id counter so its ids cannot coincide with the ones a + fresh SceneDocument mints on load - otherwise a pointer saved verbatim, + never translated, would still happen to name the right node.""" + for _ in range(count): + doc.remove_nodes([doc.add_node(0, 0).id]) + + +def test_last_chat_node_id_serializes_at_the_top_level(): + doc = SceneDocument() + doc.send_message("first") + tip = doc.send_message("second") + chat_data = build_chat_data(doc) + assert chat_data["last_chat_node_id"] == tip.id + + +def test_round_trip_restores_last_chat_node_id_to_the_tips_new_id(): + doc = SceneDocument() + _burn_ids(doc) + doc.send_message("first") + doc.send_message("second") + # Restored after the tip, so "the last chat node restored" is not the tip. + doc.add_chat_node(600, 0, "an unrelated root", True) + + doc2 = _round_trip(doc) + tip2 = next(n for n in doc2.nodes.values() if n.content == "second") + assert doc2.last_chat_node_id == tip2.id + + # The point of the pointer: reopening a chat and sending continues the + # branch it was on, rather than starting a fresh root. + third = doc2.send_message("third") + assert _has_edge(doc2, tip2.id, third.id) + + def test_round_trip_preserves_synthesize_branches_full_shape(): doc = SceneDocument() root = doc.add_chat_node(0, 0, "root question", True) From 3907db3237dcfff36cf9ca173f4373d4eab24baf Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:30 -0400 Subject: [PATCH 07/11] Never drop a finished reply, and attribute it to the model that ran - Send: if the user deleted their message while the reply was generating, the reply's command raised SceneError and the user saw "AI response failed" for a request that succeeded. It is now a silent no-op, as regenerate, conversation and image replies already were. The same guard covers artifact replies and gitlink runs, and a gitlink apply whose node was deleted no longer reports "apply failed" for files it had already written. - Provenance: the reply badge and the token counter's cost were taken from the task's default model even when the node was pinned to another one, and _on_usage re-read the dispatcher when usage arrived, so a provider switch mid-stream re-priced the reply. Both now use the request-time pair, resolved through the same pin rungs the dispatch uses. - A reply moves the continuation point inside its own recorded command, so redo puts it back on the reply. send_message's completion callback moves to module level beside regenerate's, keeping register_chat_intents under its 300-line cap. Co-Authored-By: Claude --- backend/api/intents_artifact.py | 7 + backend/api/intents_chat.py | 277 ++++++++++++++++++++------------ backend/api/intents_gitlink.py | 12 ++ backend/tests/test_agents.py | 73 +++++++++ backend/tests/test_canvas.py | 168 +++++++++++++++++++ 5 files changed, 433 insertions(+), 104 deletions(-) diff --git a/backend/api/intents_artifact.py b/backend/api/intents_artifact.py index 5f6a0996..9e5577fc 100644 --- a/backend/api/intents_artifact.py +++ b/backend/api/intents_artifact.py @@ -51,6 +51,13 @@ async def send_artifact_message(node_id, text): full_history = branch_history + node.history def _on_reply(new_content, ai_message): + if node_id not in document.nodes: + # Deleted mid-flight: silent no-op, the same posture as the + # chat, image and conversation reply callbacks. + # complete_artifact_generation goes through require_node, so + # it would raise and the dispatcher would report "Artifact + # generation failed" for a request that succeeded. + return # Same content-mutation shape as intents_chat.py's chatReply / # intents_conversation.py's appendConversationAssistantMessage - # "agent" provenance since this is a model-produced reply, not a diff --git a/backend/api/intents_chat.py b/backend/api/intents_chat.py index 156475f2..4cfed73a 100644 --- a/backend/api/intents_chat.py +++ b/backend/api/intents_chat.py @@ -226,15 +226,15 @@ def _commit(): ax, ay = document.place_child(parent_node.id, "chat") ai = document.add_chat_node(ax, ay, partial_text, False, parent_id=parent_node.id) ai.state.response_incomplete = True + # Inside the mutator so the command's after-snapshot of the chat + # pointers names this node - see _make_send_on_reply's _commit. + document.last_chat_node_id = ai.id return ai - ai_node, _command = document.record_command( - "chatReply", "agent", _commit, node_ids=[parent_node.id], - ) - document.last_chat_node_id = ai_node.id + document.record_command("chatReply", "agent", _commit, node_ids=[parent_node.id]) -def _snapshot_active_provider_model(agent_dispatcher): +def _snapshot_active_provider_model(agent_dispatcher, canvas_document=None, node_id=None, bus=None): """ADR-006 leftover #3: resolved ONCE at request-dispatch time (call this before agent_dispatcher.start_chat_reply is awaited), never at completion time - _stamp_reply_provenance used to re-read the live @@ -242,8 +242,19 @@ def _snapshot_active_provider_model(agent_dispatcher): mis-attributes a reply to whatever provider happened to be active when the LLM call FINISHED rather than the one that actually generated it (a real divergence if the user flips Settings mid-flight). Best-effort: - provenance must never fail the reply.""" + provenance must never fail the reply. + + With `canvas_document`, the node/branch/workspace model pin is resolved + by the same rungs _dispatch uses (pass the same canvas_document/node_id/ + bus the caller hands start_chat_reply), and the pair names THAT model: a + reply pinned to one of two configured models must not be badged and + priced as the task's default model.""" try: + model_ref = None + if canvas_document is not None: + model_ref = agent_dispatcher._resolve_model_ref_for_dispatch(canvas_document, node_id, bus=bus) + if model_ref is not None: + return agent_dispatcher.active_provider_model(model_ref) return agent_dispatcher.active_provider_model() except Exception: return None, None @@ -259,7 +270,8 @@ def _stamp_reply_provenance(node, provider, model) -> None: def _make_on_usage( - document, agent_dispatcher, token_counter, publish_token_counter, publish_scene, node_ref + document, token_counter, publish_token_counter, publish_scene, node_ref, + provider=None, model=None, ): """ADR-006 stage 6.8: the shared real-usage completion callback for both reply flows (module-level for the same 300-line-cap reason as its @@ -268,10 +280,16 @@ def _make_on_usage( the regenerated node). Records the counter's real usage (with provider/model for cost estimation) and stamps the counts on the node. Partials and cancels never reach here - no usage exists for dead - streams, so they keep the estimator.""" + streams, so they keep the estimator. + + `provider`/`model` are the request-time pair from + _snapshot_active_provider_model, for the same reason + _stamp_reply_provenance takes them: re-reading the dispatcher when the + usage arrives would price the reply against whatever is active THEN (a + provider switch mid-stream) or against the task's default model when + the reply ran on a pinned one.""" async def _on_usage(usage): - provider, model = agent_dispatcher.active_provider_model() token_counter.set_real_usage( usage.get("prompt_tokens"), usage.get("completion_tokens"), provider=provider, model=model, @@ -293,6 +311,131 @@ async def _on_usage(usage): return _on_usage +def _make_send_on_reply( + document, node, token_counter, publish_token_counter, reply_ref, + provenance_provider=None, provenance_model=None, +): + """send_message's completion callback, built at module level for the + same 300-line-cap reason as _make_regenerate_on_reply below. `node` is + the user's just-sent chat node; `reply_ref` is send_message's + {"node_id": ...} box, filled here once the reply node exists so + _on_usage can stamp the real counts onto it. `provenance_provider`/ + `provenance_model` are the request-time pair from + _snapshot_active_provider_model.""" + + async def _on_reply(reply_text): + # R8a: outputTokens reflects what the model actually returned, + # regardless of whether parse_response below finds anything + # node-worthy in it (the empty-reply early return below) or the + # user's node is still there to hang it off (the liveness check + # just below) - the reply happened and consumed real output + # tokens either way. Same ahead-of-every-early-return placement + # as regenerate's own _on_reply (_make_regenerate_on_reply). + token_counter.set_output_text(reply_text) + await publish_token_counter() + # Deleted mid-flight: silent no-op, the same posture as + # regenerate's _on_reply, send_message's own _on_partial, + # and intents_conversation.py's _on_reply. The user's node can be + # removed (removeNodes/deleteChatNode) while the reply is still + # generating - _dispatch runs the LLM call as an un-awaited task, + # so the socket is free to process the delete first - and + # _build_reply_nodes would then raise SceneError("unknown parent + # node"), which _dispatch's generic handler reports as "AI + # response failed" for a request that succeeded. Returning here, + # BEFORE add_session_tokens and before reply_ref is set, is what + # keeps the document's bookkeeping honest: the persisted session + # total only grows for replies that land, and _on_usage (which + # still runs after this returns) finds reply_ref unset and + # stamps no node, while the request-level counter keeps the + # real usage the provider billed. + if node.id not in document.nodes: + return + # R6.3: the assistant's reply has completed (regardless of + # whether parse_response below finds anything node-worthy in + # it) - grow total_session_tokens by its estimated count too, + # same as the user's own message text just above. + document.add_session_tokens(reply_text) + # R4.3b: port legacy handle_response's _parse_response retrofit - + # split the flat reply into thinking/text/code parts and create + # separate thinking-kind/code-kind CHILD nodes instead of + # dumping the raw, unparsed reply into one flat node. + parsed_parts = parse_response(reply_text) + if not parsed_parts: + # Mirrors legacy handle_response's own outer gate + # (`if text_content or parsed_parts:`) - a genuinely empty/ + # whitespace-only reply creates NO node at all, not a + # "[Empty Response]" placeholder node. Currently unreachable + # in practice (api_provider._compose_reasoned_response raises + # rather than returning blank content), but the gate is kept + # so a future provider path can never silently diverge from + # legacy here. last_chat_node_id is deliberately left + # untouched - it already points at the user's own message + # node (set by send_message), matching legacy's + # own fallback of leaving current_node at user_node when no + # assistant node gets created. + return + + text_parts = [p["content"] for p in parsed_parts if p["type"] == "text"] + text_content = "\n\n".join(text_parts) + + placeholder_text = text_content + if not placeholder_text: + if any(p["type"] == "code" for p in parsed_parts): + placeholder_text = PLACEHOLDER_GENERATED_CONTENT + elif any(p["type"] == "thinking" for p in parsed_parts): + placeholder_text = PLACEHOLDER_ASSISTANT_REASONING + else: + # Unreachable given parse_response's own invariants (a + # non-empty parts list with no text/code part must + # contain a thinking part) - legacy's handle_response has + # this exact same dead branch; kept verbatim for + # structural parity rather than optimized away. + placeholder_text = PLACEHOLDER_EMPTY_RESPONSE + + # ADR-010 stage 10.1: reply node + children as ONE command (see + # _build_reply_nodes). "agent" provenance, not "user" - stage 10.5 + # ("undo this build") is what consumes that distinction. + def _commit(): + ai = _build_reply_nodes(document, node, placeholder_text, parsed_parts) + # Always the real chat node's id, never a code/thinking child's - + # add_code_node/add_thinking_node are documented (see their own + # docstrings) as NOT branch points, and last_chat_node_id + # specifically drives the next real send's branch-continuation + # (chat_branch_history), which only makes sense pointed at a real + # chat node. Set inside the mutator, not after record_command + # returns, so the command's after-snapshot of the chat pointers + # names this reply: undo puts the continuation point back on the + # user's message and redo brings it forward to the reply again. + document.last_chat_node_id = ai.id + return ai + + ai_node, _command = document.record_command( + "chatReply", "agent", _commit, node_ids=[node.id], + ) + # ADR-006 stage 6.8: stamp provider/model provenance on the + # reply (previously only branch-synthesis nodes carried it), + # and remember the node for _on_usage's per-node token stamp. + _stamp_reply_provenance(ai_node, provenance_provider, provenance_model) + reply_ref["node_id"] = ai_node.id + + # NOTE: the calls inside _create_reply_nodes above MUST use the + # `document.` prefix. Before ADR-002 stage 2.6, a bare + # `add_code_node(...)`/`add_thinking_node(...)` here would have + # silently resolved to register_canvas's own same-scope async + # WS-intent wrapper closures of the same name instead of raising + # a NameError - producing an unawaited coroutine that never ran + # and never errored, so no node would be created and nothing + # would look wrong until the scene state was actually inspected. + # Those wrapper closures now live in backend/api/intents_nodes.py, + # a different module - a bare call here would raise NameError + # immediately instead, a strictly SAFER failure mode than the + # original silent one. The `document.` prefix requirement is + # kept regardless, since correctness should not depend on which + # of the two failure modes happens to be true today. + + return _on_reply + + def _make_regenerate_on_reply( document, bus, notifications, token_counter, publish_token_counter, node_to_regenerate, provenance_provider=None, provenance_model=None, @@ -399,7 +542,7 @@ def register_chat_intents( ) -> None: # Deferred import - see this module's own docstring for why # _history_token_text cannot be imported at module level here. - from backend.canvas import _history_token_text + from backend.canvas import _history_image_tokens, _history_token_text publish_scene = make_publish_scene(bus) publish_token_counter = make_publish_token_counter(bus) @@ -460,7 +603,9 @@ async def send_message(text, branch_from_node_id=None): context_history = ( document.chat_branch_history(context_parent_edge.source) if context_parent_edge else [] ) - token_counter.set_context_text(_history_token_text(context_history)) + token_counter.set_context_text( + _history_token_text(context_history), image_tokens=_history_image_tokens(context_history), + ) # 6.8 review fix: a new request starts on estimates - see # TokenCounterState.reset_real_usage. token_counter.reset_real_usage() @@ -473,94 +618,14 @@ async def send_message(text, branch_from_node_id=None): # ADR-006 leftover #3: resolved here, at request-dispatch time - not # inside _on_reply below, which only fires once the reply completes # (see _snapshot_active_provider_model's own note). - request_provider, request_model = _snapshot_active_provider_model(agent_dispatcher) - - async def _on_reply(reply_text): - # R6.3: the assistant's reply has completed (regardless of - # whether parse_response below finds anything node-worthy in - # it) - grow total_session_tokens by its estimated count too, - # same as the user's own message text just above. - document.add_session_tokens(reply_text) - # R8a: outputTokens reflects what the model actually returned, - # regardless of whether parse_response below finds anything - # node-worthy in it (the empty-reply early return just below) - # - the reply happened and consumed real output tokens either - # way. - token_counter.set_output_text(reply_text) - await publish_token_counter() - # R4.3b: port legacy handle_response's _parse_response retrofit - - # split the flat reply into thinking/text/code parts and create - # separate thinking-kind/code-kind CHILD nodes instead of - # dumping the raw, unparsed reply into one flat node. - parsed_parts = parse_response(reply_text) - if not parsed_parts: - # Mirrors legacy handle_response's own outer gate - # (`if text_content or parsed_parts:`) - a genuinely empty/ - # whitespace-only reply creates NO node at all, not a - # "[Empty Response]" placeholder node. Currently unreachable - # in practice (api_provider._compose_reasoned_response raises - # rather than returning blank content), but the gate is kept - # so a future provider path can never silently diverge from - # legacy here. last_chat_node_id is deliberately left - # untouched - it already points at the user's own message - # node (set by send_message just above), matching legacy's - # own fallback of leaving current_node at user_node when no - # assistant node gets created. - return - - text_parts = [p["content"] for p in parsed_parts if p["type"] == "text"] - text_content = "\n\n".join(text_parts) - - placeholder_text = text_content - if not placeholder_text: - if any(p["type"] == "code" for p in parsed_parts): - placeholder_text = PLACEHOLDER_GENERATED_CONTENT - elif any(p["type"] == "thinking" for p in parsed_parts): - placeholder_text = PLACEHOLDER_ASSISTANT_REASONING - else: - # Unreachable given parse_response's own invariants (a - # non-empty parts list with no text/code part must - # contain a thinking part) - legacy's handle_response has - # this exact same dead branch; kept verbatim for - # structural parity rather than optimized away. - placeholder_text = PLACEHOLDER_EMPTY_RESPONSE - - # ADR-010 stage 10.1: reply node + children as ONE command (see - # _build_reply_nodes). "agent" provenance, not "user" - stage 10.5 - # ("undo this build") is what consumes that distinction. - ai_node, _command = document.record_command( - "chatReply", "agent", - lambda: _build_reply_nodes(document, node, placeholder_text, parsed_parts), - node_ids=[node.id], - ) - # ADR-006 stage 6.8: stamp provider/model provenance on the - # reply (previously only branch-synthesis nodes carried it), - # and remember the node for _on_usage's per-node token stamp. - _stamp_reply_provenance(ai_node, request_provider, request_model) - reply_ref["node_id"] = ai_node.id - - # NOTE: the calls inside _create_reply_nodes above MUST use the - # `document.` prefix. Before ADR-002 stage 2.6, a bare - # `add_code_node(...)`/`add_thinking_node(...)` here would have - # silently resolved to register_canvas's own same-scope async - # WS-intent wrapper closures of the same name instead of raising - # a NameError - producing an unawaited coroutine that never ran - # and never errored, so no node would be created and nothing - # would look wrong until the scene state was actually inspected. - # Those wrapper closures now live in backend/api/intents_nodes.py, - # a different module - a bare call here would raise NameError - # immediately instead, a strictly SAFER failure mode than the - # original silent one. The `document.` prefix requirement is - # kept regardless, since correctness should not depend on which - # of the two failure modes happens to be true today. + request_provider, request_model = _snapshot_active_provider_model( + agent_dispatcher, document, node.id, bus, + ) - # Always the real chat node's id, never a code/thinking child's - - # add_code_node/add_thinking_node are documented above (see their - # own docstrings) as NOT branch points, and last_chat_node_id - # specifically drives the next real send's branch-continuation - # (chat_branch_history), which only makes sense pointed at a real - # chat node. - document.last_chat_node_id = ai_node.id + _on_reply = _make_send_on_reply( + document, node, token_counter, publish_token_counter, reply_ref, + request_provider, request_model, + ) async def _on_partial(partial_text): # See _land_partial_reply_node; token accounting mirrors @@ -573,8 +638,8 @@ async def _on_partial(partial_text): _land_partial_reply_node(document, node, partial_text) _on_usage = _make_on_usage( - document, agent_dispatcher, token_counter, publish_token_counter, - publish_scene, lambda: reply_ref["node_id"], + document, token_counter, publish_token_counter, publish_scene, + lambda: reply_ref["node_id"], request_provider, request_model, ) await agent_dispatcher.start_chat_reply( @@ -620,7 +685,9 @@ async def regenerate_response(node_id): # not node_id) - there is no fresh draft text to double-count # against, so it's usable directly as contextTokens with no # adjustment. - token_counter.set_context_text(_history_token_text(history)) + token_counter.set_context_text( + _history_token_text(history), image_tokens=_history_image_tokens(history), + ) # 6.8 review fix: same request-start reset as send_message above. token_counter.reset_real_usage() await publish_token_counter() @@ -628,7 +695,9 @@ async def regenerate_response(node_id): # ADR-006 leftover #3: resolved here, before start_chat_reply is # awaited below - not inside _on_reply, which only fires once the # reply completes (see _snapshot_active_provider_model's own note). - request_provider, request_model = _snapshot_active_provider_model(agent_dispatcher) + request_provider, request_model = _snapshot_active_provider_model( + agent_dispatcher, document, parent_id, bus, + ) _on_reply = _make_regenerate_on_reply( document, bus, notifications, token_counter, publish_token_counter, node_to_regenerate, request_provider, request_model, @@ -641,8 +710,8 @@ def _on_partial(partial_text): _land_partial_regenerate(document, node_to_regenerate, partial_text) _on_usage = _make_on_usage( - document, agent_dispatcher, token_counter, publish_token_counter, - publish_scene, lambda: node_to_regenerate.id, + document, token_counter, publish_token_counter, publish_scene, + lambda: node_to_regenerate.id, request_provider, request_model, ) await agent_dispatcher.start_chat_reply( diff --git a/backend/api/intents_gitlink.py b/backend/api/intents_gitlink.py index df2168e8..57d7c9d3 100644 --- a/backend/api/intents_gitlink.py +++ b/backend/api/intents_gitlink.py @@ -151,6 +151,11 @@ async def run_gitlink_change_set(node_id, task_prompt): await publish_scene() def _on_success(proposal_markdown, pending_changes, preview_text, fingerprint, local_root): + if node_id not in document.nodes: + # Deleted mid-run: silent no-op (see intents_artifact.py's + # _on_reply). complete_gitlink_run would raise and the run + # would be reported as failed. + return document.complete_gitlink_run(node_id, proposal_markdown, pending_changes, preview_text, fingerprint, local_root) @@ -178,6 +183,13 @@ async def apply_gitlink_changes(node_id, fingerprint): return None def _on_success(written_files): + if node_id not in document.nodes: + # The files ARE written by the time this runs. With the node + # gone there is nothing left to mark applied, and raising + # here would turn the dispatcher's truthful "Applied N file + # changes." into "Gitlink apply failed" for writes that + # happened. + return document.complete_gitlink_apply(node_id, written_files) def _on_failure(message): diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index a10625ce..6158d7c5 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -651,6 +651,79 @@ async def run(): asyncio.run(run()) +def test_a_pinned_send_stamps_the_pinned_model_on_the_reply_not_the_task_default(monkeypatch): + # The reply's model badge and the token counter's cost lookup both read + # the request-time provenance pair. A pinned dispatch runs the pinned + # model, so the pair must name it - a snapshot of the task's default + # would badge (and price) the reply as a model that never ran, which is + # exactly the side-by-side comparison a pin exists for. + _configure_fake_ollama_provider_only(monkeypatch, model="task-default-model") + + def fake_stream(conversation_history, persona_text, cancel_event, on_chunk, **kwargs): + on_chunk("a reply", False) + return "a reply" + + monkeypatch.setattr(agents_module, "_call_chat_agent_stream", fake_stream) + + async def run(): + bus = SessionBus("agents-pinned-provenance-test") + notifications = NotificationState() + bus.register_topic("notification", notifications.payload) + composer_document = ComposerDocument() + bus.register_topic("app-composer", composer_document.payload) + dispatcher = AgentDispatcher(_FakeSettingsManager()) + document = register_canvas(bus, notifications, dispatcher, composer_document) + + root = document.add_chat_node(0, 0, "root message", True) + document.last_chat_node_id = root.id + document.set_model_override(root.id, "Anthropic Claude", "claude-opus-5") + + user_id = await bus.dispatch_intent("scene", "sendMessage", ["continue the branch"]) + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + reply = document.nodes[document.last_chat_node_id] + assert reply.id != user_id + assert (reply.state.provider, reply.state.model) == ("Anthropic Claude", "claude-opus-5") + + asyncio.run(run()) + + +def test_on_usage_prices_the_reply_against_the_request_time_pair(): + # _on_usage must not re-read the dispatcher when the usage arrives: a + # provider switch mid-stream, or a pinned dispatch, would otherwise + # price the reply against a model that did not produce it. + from backend.api.intents_chat import _make_on_usage + + document = SceneDocument() + node = document.add_chat_node(0, 0, "a reply", False) + seen = [] + + class _RecordingCounter: + def set_real_usage(self, prompt_tokens, completion_tokens, *, provider, model): + seen.append(("usage", provider, model)) + + def estimate_cost_for(self, prompt_tokens, completion_tokens, *, provider, model): + seen.append(("cost", provider, model)) + return 0.25 + + async def _publish(): + return None + + on_usage = _make_on_usage( + document, _RecordingCounter(), _publish, _publish, lambda: node.id, + "Anthropic Claude", "claude-opus-5", + ) + asyncio.run(on_usage({"prompt_tokens": 10, "completion_tokens": 5})) + + assert seen == [ + ("usage", "Anthropic Claude", "claude-opus-5"), + ("cost", "Anthropic Claude", "claude-opus-5"), + ] + assert node.state.estimated_cost_usd == 0.25 + assert (node.state.prompt_tokens, node.state.completion_tokens) == (10, 5) + + def test_send_message_omits_model_ref_entirely_when_nothing_is_pinned(monkeypatch): # The behavior-preservation half of 18.2: no pin anywhere means # _call_chat_agent_stream never even RECEIVES a model_ref kwarg - diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 1bb93fce..8143d430 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -2001,6 +2001,84 @@ async def send(text, *extra_args): asyncio.run(run()) +@pytest.mark.parametrize( + "intent, args_for", + [ + ("removeNodes", lambda node_id: [[node_id]]), + ("deleteChatNode", lambda node_id: [node_id]), + ], +) +def test_send_message_user_node_deleted_mid_flight_is_a_silent_noop(intent, args_for): + # The send path's completion callback must match regenerate's (and + # intents_conversation's) posture when the node the reply hangs off was + # deleted while the model was still generating: a silent no-op, never a + # SceneError surfaced as "AI response failed" for a request that + # succeeded. Nothing may record or stamp the reply that never landed - + # no orphan node, no per-node usage stamp, no session-token growth - + # while the request-level output meter still reflects what the model + # returned, exactly as regenerate's own deleted-mid-flight path does. + async def run(): + bus, document, recorder, dispatcher = make_bus_with_dispatcher() + + def first_reply(task, messages, **kwargs): + return {"message": {"content": "first reply"}} + + with patch.object(api_provider, "USE_API_MODE", False), \ + patch.object(api_provider, "LOCAL_PROVIDER_TYPE", task_config.LOCAL_PROVIDER_OLLAMA), \ + patch.dict(task_config.OLLAMA_MODELS, {task_config.TASK_CHAT: "test-model"}), \ + patch.object(api_provider, "chat", first_reply): + first_user_id = await bus.dispatch_intent("scene", "sendMessage", ["hello"]) + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + surviving_ids = set(document.nodes) + first_reply_id = document.last_chat_node_id + assert first_reply_id != first_user_id and len(surviving_ids) == 2 + + started = threading.Event() + release = threading.Event() + late_reply = "a completed reply that has nowhere to land" + + def blocking_chat(task, messages, **kwargs): + started.set() + release.wait(5) + return { + "message": {"content": late_reply}, + "usage": {"prompt_tokens": 321, "completion_tokens": 54}, + } + + with patch.object(api_provider, "USE_API_MODE", False), \ + patch.object(api_provider, "LOCAL_PROVIDER_TYPE", task_config.LOCAL_PROVIDER_OLLAMA), \ + patch.dict(task_config.OLLAMA_MODELS, {task_config.TASK_CHAT: "test-model"}), \ + patch.object(api_provider, "chat", blocking_chat): + follow_up_id = await bus.dispatch_intent("scene", "sendMessage", ["follow up"]) + await asyncio.to_thread(started.wait, 5) + session_tokens_at_delete = document.total_session_tokens + + await bus.dispatch_intent("scene", intent, args_for(follow_up_id)) + assert follow_up_id not in document.nodes + active_after_delete = document.last_chat_node_id + + release.set() + entry = next(iter(chat_slots(dispatcher).values())) + await entry["task"] + + notice = await bus.publish("notification") + assert notice["visible"] is False, "deleted-mid-flight is a silent no-op - no notification fires" + assert set(document.nodes) == surviving_ids, "no orphan reply node is created anywhere" + assert all( + getattr(n.state, "prompt_tokens", None) is None for n in document.nodes.values() + ), "the reply's real usage must not be stamped on any surviving node" + assert document.total_session_tokens == session_tokens_at_delete, ( + "a reply that never landed must not grow the persisted session total" + ) + assert document.last_chat_node_id == active_after_delete, "the late reply never moves the active branch" + # Request-level meter: the model really returned (and billed) this. + assert bus.token_counter.output_tokens == estimate_tokens(late_reply) + + asyncio.run(run()) + + # -- history-to-text flattening (R8a token counter wiring) ------------------- @@ -4038,6 +4116,96 @@ async def run(): asyncio.run(run()) +class _CapturingRunDispatcher: + """Records the callbacks a run intent hands the dispatcher, so a test can + fire them after deleting the node - the mid-flight-delete window.""" + + def __init__(self): + self.calls = [] + + async def start_artifact_reply(self, **kwargs): + self.calls.append(kwargs) + + async def start_gitlink_run(self, **kwargs): + self.calls.append(kwargs) + + async def start_gitlink_apply(self, **kwargs): + self.calls.append(kwargs) + + +def _register_with_capturing_dispatcher(session_id): + bus = SessionBus(session_id) + notifications = NotificationState() + bus.register_topic("notification", notifications.payload) + composer_document = ComposerDocument() + bus.register_topic("app-composer", composer_document.payload) + dispatcher = _CapturingRunDispatcher() + document = register_canvas(bus, notifications, dispatcher, composer_document) + return bus, document, dispatcher + + +def test_an_artifact_reply_landing_after_its_node_was_deleted_is_a_silent_noop(): + # complete_artifact_generation goes through require_node; without the + # liveness guard the late reply raised SceneError, which the dispatcher + # reported as "Artifact generation failed" for a request that succeeded. + async def run(): + bus, document, dispatcher = _register_with_capturing_dispatcher("artifact-midflight-delete-test") + root = await bus.dispatch_intent("scene", "addChatNode", [0, 0, "draft me a brief", True]) + art_node = document.add_artifact_node(0, 0, root) + await bus.dispatch_intent("scene", "sendArtifactMessage", [art_node.id, "draft the introduction"]) + on_reply = dispatcher.calls[0]["on_reply"] + + document.remove_nodes([art_node.id]) + commands_before = len(document.command_log) + on_reply("# The document", "Here is the draft.") + + assert art_node.id not in document.nodes + assert len(document.command_log) == commands_before + + asyncio.run(run()) + + +def test_a_gitlink_run_landing_after_its_node_was_deleted_is_a_silent_noop(): + async def run(): + bus, document, dispatcher = _register_with_capturing_dispatcher("gitlink-run-midflight-delete-test") + parent = document.add_node(0, 0, "parent") + node = document.add_gitlink_node(0, 0, parent.id) + document.store_gitlink_repo_tree(node.id, "octocat/hello-world", "main", ["a.py"]) + document.store_gitlink_context( + node.id, scope_mode="selected", selected_paths=["a.py"], context_xml="", + context_stats={}, context_summary="s", + ) + await bus.dispatch_intent("scene", "runGitlinkChangeSet", [node.id, "add a feature"]) + on_success = dispatcher.calls[0]["on_success"] + + document.remove_nodes([node.id]) + on_success("## Proposal", [], "preview", "fp-1", "") + + assert node.id not in document.nodes + + asyncio.run(run()) + + +def test_a_gitlink_apply_finishing_after_its_node_was_deleted_does_not_report_a_failure(): + # The files are already written when on_success runs; raising there + # replaced the dispatcher's "Applied N file changes." with "Gitlink + # apply failed" for writes that really happened. + async def run(): + bus, document, dispatcher = _register_with_capturing_dispatcher("gitlink-apply-midflight-delete-test") + parent = document.add_node(0, 0, "parent") + node = document.add_gitlink_node(0, 0, parent.id) + document.set_gitlink_local_root(node.id, "C:/checkout") + await bus.dispatch_intent("scene", "applyGitlinkChanges", [node.id, "fp-123"]) + on_success = dispatcher.calls[0]["on_success"] + + document.remove_nodes([node.id]) + on_success(3) + + assert node.id not in document.nodes + + asyncio.run(run()) + + def test_cancel_gitlink_request_intent_calls_agent_dispatcher_cancel_gitlink(): class _FakeDispatcher: def __init__(self): From c53a034734b7f8cae854cb8b7bfc85abe74ae74e Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:31 -0400 Subject: [PATCH 08/11] Stop a group drag from freezing canvas updates Dragging a container or frame with members built the members' synthetic position changes with a hardcoded dragging: true. The middleware also runs on React Flow's drag-stop change, so the release batch ended with member changes still flagged as dragging, and onNodesChange re-opened the gesture it had just closed. draggingRef stayed set, and the scene-sync effect skipped every later publish: new nodes never appeared, streamed replies never updated and deleted nodes stayed on screen until some non-group node was dragged. Member changes now carry the drag flag of the change that produced them, and the parameter is required so no caller can drop it. Tests drive a group drag through the real middleware and onNodesChange, and through the canvas, where a scene update after release is applied. Co-Authored-By: Claude --- web_ui/src/app/canvas/SceneCanvas.test.tsx | 41 +++++- web_ui/src/app/canvas/SceneCanvas.tsx | 17 ++- .../SceneCanvas.virtualization.test.tsx | 37 +++++ .../canvas/useNodeDragAndSizeSync.test.tsx | 134 ++++++++++++++++++ .../src/app/canvas/useNodeDragAndSizeSync.ts | 6 +- 5 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 web_ui/src/app/canvas/useNodeDragAndSizeSync.test.tsx diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx index d829fe62..96fabce8 100644 --- a/web_ui/src/app/canvas/SceneCanvas.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx @@ -2303,7 +2303,7 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { ]; // The frame itself moved from (100,100) to (140,130): delta (40,30). - const changes = applyGroupDragDelta(nodes, "frame-1", { x: 140, y: 130 }); + const changes = applyGroupDragDelta(nodes, "frame-1", { x: 140, y: 130 }, true); expect(changes).toHaveLength(2); const typedChanges = changes as unknown as Array<{ id: string; position: { x: number; y: number } }>; @@ -2318,6 +2318,33 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { } }); + it("member changes carry the producing change's drag flag, so a drag-stop batch never re-opens the gesture", () => { + const nodes: SceneFlowNode[] = [ + { + id: "outer-container", + type: "container", + position: { x: 0, y: 0 }, + data: { itemIds: ["inner-frame"] }, + } as unknown as SceneFlowNode, + { + id: "inner-frame", + type: "frame", + position: { x: 50, y: 50 }, + data: { isLocked: true, itemIds: ["leaf-1"] }, + } as unknown as SceneFlowNode, + { id: "leaf-1", type: "chat", position: { x: 70, y: 90 }, data: {} } as unknown as SceneFlowNode, + ]; + + // React Flow's drag-STOP change carries no dragging flag; every member + // change appended to that stop batch - nested ones included - must not + // claim dragging either. + const changes = applyGroupDragDelta(nodes, "outer-container", { x: 10, y: 20 }, false); + expect(changes).toEqual([ + { id: "inner-frame", type: "position", dragging: false, position: { x: 60, y: 70 } }, + { id: "leaf-1", type: "position", dragging: false, position: { x: 80, y: 110 } }, + ]); + }); + it("an unlocked frame's drag carries no members", () => { const nodes: SceneFlowNode[] = [ { @@ -2328,7 +2355,7 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { } as unknown as SceneFlowNode, { id: "member-1", type: "chat", position: { x: 120, y: 140 }, data: {} } as unknown as SceneFlowNode, ]; - expect(applyGroupDragDelta(nodes, "frame-1", { x: 140, y: 130 })).toEqual([]); + expect(applyGroupDragDelta(nodes, "frame-1", { x: 140, y: 130 }, true)).toEqual([]); }); it("a container's drag carries every member by the identical delta unconditionally", () => { @@ -2341,7 +2368,7 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { } as unknown as SceneFlowNode, { id: "member-1", type: "note", position: { x: 50, y: 50 }, data: {} } as unknown as SceneFlowNode, ]; - const changes = applyGroupDragDelta(nodes, "container-1", { x: -10, y: 5 }); + const changes = applyGroupDragDelta(nodes, "container-1", { x: -10, y: 5 }, true); expect(changes).toEqual([ { id: "member-1", type: "position", dragging: true, position: { x: 40, y: 55 } }, ]); @@ -2351,7 +2378,7 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { const nodes: SceneFlowNode[] = [ { id: "chat-1", type: "chat", position: { x: 0, y: 0 }, data: {} } as unknown as SceneFlowNode, ]; - expect(applyGroupDragDelta(nodes, "chat-1", { x: 10, y: 10 })).toEqual([]); + expect(applyGroupDragDelta(nodes, "chat-1", { x: 10, y: 10 }, true)).toEqual([]); }); it("skips a member id that no longer resolves to a live node", () => { @@ -2363,7 +2390,7 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { data: { isLocked: true, itemIds: ["gone"] }, } as unknown as SceneFlowNode, ]; - expect(applyGroupDragDelta(nodes, "frame-1", { x: 10, y: 10 })).toEqual([]); + expect(applyGroupDragDelta(nodes, "frame-1", { x: 10, y: 10 }, true)).toEqual([]); }); it("R6.1 follow-up: dragging an outer container cascades into a NESTED group's own members too", () => { @@ -2390,7 +2417,7 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { ]; // The outer container moved from (0,0) to (10,20): delta (10,20). - const changes = applyGroupDragDelta(nodes, "outer-container", { x: 10, y: 20 }); + const changes = applyGroupDragDelta(nodes, "outer-container", { x: 10, y: 20 }, true); const byId = new Map( (changes as unknown as Array<{ id: string; position: { x: number; y: number } }>).map((c) => [c.id, c]), @@ -2417,7 +2444,7 @@ describe("applyGroupDragDelta (R6.1 group-drag)", () => { } as unknown as SceneFlowNode, ]; - expect(() => applyGroupDragDelta(nodes, "container-a", { x: 5, y: 5 })).not.toThrow(); + expect(() => applyGroupDragDelta(nodes, "container-a", { x: 5, y: 5 }, true)).not.toThrow(); }); }); diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index ddd96b5c..45b844e1 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -1990,16 +1990,26 @@ export function groupDragKindOf(node: SceneFlowNode | undefined): "frame" | "con // new (already drag-speed-scaled) target position for this tick. Returns one // synthetic "position" NodeChange per live member, carrying it by the exact // same delta the dragged group node itself is about to move by. +// +// `dragging` is the drag flag of the change that produced this delta, and +// every member change carries it verbatim. The middleware also runs on React +// Flow's drag-STOP change (dragging unset) and appends these member changes +// to that same stop batch; onNodesChange reads drag state change by change, +// so a single member change flagged dragging there re-opens the gesture the +// stop just closed - draggingRef stays set and the scene-sync effect skips +// every later scene publish. Required, not defaulted, so no caller can +// silently reintroduce that. export function applyGroupDragDelta( nodes: SceneFlowNode[], draggedId: string, scaledPosition: { x: number; y: number }, + dragging: boolean, ): NodeChange[] { const draggedNode = nodes.find((n) => n.id === draggedId); if (!groupDragKindOf(draggedNode) || !draggedNode) return []; const deltaX = scaledPosition.x - draggedNode.position.x; const deltaY = scaledPosition.y - draggedNode.position.y; - return collectGroupMemberDeltaChanges(nodes, draggedNode, deltaX, deltaY, new Set([draggedId])); + return collectGroupMemberDeltaChanges(nodes, draggedNode, deltaX, deltaY, dragging, new Set([draggedId])); } // R6.1 follow-up: recurses into any member that is itself a group (a @@ -2018,6 +2028,7 @@ function collectGroupMemberDeltaChanges( groupNode: SceneFlowNode, deltaX: number, deltaY: number, + dragging: boolean, visited: Set, ): NodeChange[] { const itemIds = (groupNode.data as { itemIds?: string[] }).itemIds ?? []; @@ -2030,11 +2041,11 @@ function collectGroupMemberDeltaChanges( changes.push({ id: memberId, type: "position", - dragging: true, + dragging, position: { x: member.position.x + deltaX, y: member.position.y + deltaY }, }); if (member.type === "frame" || member.type === "container") { - changes.push(...collectGroupMemberDeltaChanges(nodes, member, deltaX, deltaY, visited)); + changes.push(...collectGroupMemberDeltaChanges(nodes, member, deltaX, deltaY, dragging, visited)); } } return changes; diff --git a/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx b/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx index e2141849..4145ada1 100644 --- a/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx @@ -474,6 +474,43 @@ describe("SceneCanvas wiring (ADR-011 stages 11.2/11.3)", () => { expect(remoteMovedA.position).toEqual({ x: 999, y: 0 }); }); + it("keeps reconciling scene updates after a group carrying members is dragged and released", () => { + // A container's drag appends synthetic position changes for its + // members to the very batch that carries React Flow's drag-STOP change + // (see dragCorrectionMiddleware). If those member changes are flagged + // dragging, the stop batch re-opens the gesture it just closed and + // this effect skips every scene publish from then on. + const { stateListeners } = mount(); + const container = { ...chatRow("g", 0), kind: "container", itemIds: ["m1", "m2"], groupWidth: 300, groupHeight: 300 }; + const members = [chatRow("m1", 20, 20), chatRow("m2", 100, 100)]; + publish(stateListeners, { nodes: [container, ...members], edges: [] }); + + const drag = (change: Partial & { id: string }) => + act(() => { + const raw = [{ type: "position", ...change } as NodeChange]; + lastProps().onNodesChange(capturedMiddleware ? capturedMiddleware(raw) : raw); + }); + + drag({ id: "g", dragging: true, position: { x: 10, y: 10 } }); + expect(lastProps().onlyRenderVisibleElements).toBe(false); + drag({ id: "g", dragging: true, position: { x: 40, y: 10 } }); + drag({ id: "g", dragging: false, position: { x: 40, y: 10 } }); + // Off-viewport culling resumes: the gesture is over. + expect(lastProps().onlyRenderVisibleElements).toBe(true); + + // A later, unrelated publish (a new node) must reach the canvas. + publish(stateListeners, { + nodes: [{ ...container, x: 40, y: 10 }, chatRow("m1", 60, 30), chatRow("m2", 140, 110), chatRow("c", 900)], + edges: [], + }); + expect(capturedStoreApiRef.current!.getState().nodes.map((n: { id: string }) => n.id).sort()).toEqual([ + "c", + "g", + "m1", + "m2", + ]); + }); + it("lets a remote position win when it is the first scene update after the drop", () => { const { stateListeners } = mount(); publish(stateListeners, { nodes: [chatRow("a", 0), chatRow("b", 200)], edges: [] }); diff --git a/web_ui/src/app/canvas/useNodeDragAndSizeSync.test.tsx b/web_ui/src/app/canvas/useNodeDragAndSizeSync.test.tsx new file mode 100644 index 00000000..f2874cca --- /dev/null +++ b/web_ui/src/app/canvas/useNodeDragAndSizeSync.test.tsx @@ -0,0 +1,134 @@ +/** + * useNodeDragAndSizeSync's drag-gesture bookkeeping, driven through the + * hook's own two halves in production order: the change middleware React + * Flow runs INSIDE its own update, then onNodesChange with the middleware's + * output. Same capture-the-middleware posture as SceneCanvas.virtualization. + * test.tsx (see that file's module doc for why a real mount + * cannot be driven here), but at the hook level so the gesture state the + * hook returns - draggingRef, dragActive, smartGuideLines - can be asserted + * directly rather than inferred from props. + */ +import { ReactFlowProvider, useReactFlow, type NodeChange } from "@xyflow/react"; +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +let capturedMiddleware: ((changes: NodeChange[]) => NodeChange[]) | null = null; + +vi.mock("@xyflow/react", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + experimental_useOnNodesChangeMiddleware: (fn: (changes: NodeChange[]) => NodeChange[]) => { + capturedMiddleware = fn; + }, + }; +}); + +import { useNodeDragAndSizeSync } from "./useNodeDragAndSizeSync"; +import { initialSceneState, type SceneStore } from "./sceneStore"; +import type { SceneFlowNode } from "./SceneCanvas"; +import type { SceneState } from "../../lib/bridge-core/generated/scene-state"; + +function flowNode( + id: string, + type: string, + x: number, + y: number, + size: number, + data: Record = {}, +): SceneFlowNode { + // width/height on the flow node itself is what buildDragSizeCache reads + // first (flowNodeOwnSize), so the smart-guide cache resolves every node + // here without a DOM to measure. + return { id, type, position: { x, y }, width: size, height: size, data } as unknown as SceneFlowNode; +} + +function mountHook() { + const store = { moveNodes: vi.fn(), reportNodeSizes: vi.fn() } as unknown as SceneStore; + const scene: SceneState = { ...initialSceneState, smartGuides: true }; + capturedMiddleware = null; + const { result } = renderHook(() => useNodeDragAndSizeSync(scene, useReactFlow(), store), { + wrapper: ReactFlowProvider, + }); + // A container at the origin carrying two members, plus one unrelated + // node whose top edge (y=0) the container's top edge aligns with - the + // smart-guide candidate that makes each drag frame publish a guide line. + result.current.nodesRef.current = [ + flowNode("g", "container", 0, 0, 200, { itemIds: ["m1", "m2"] }), + flowNode("m1", "chat", 20, 20, 50), + flowNode("m2", "chat", 100, 100, 50), + flowNode("anchor", "chat", 500, 0, 100), + ]; + // Production's real sequence: React Flow runs the registered middleware + // inside its own update first, then hands the CORRECTED batch to + // onNodesChange. + const frame = (changes: Array & { id: string }>) => + act(() => { + const raw = changes.map((change) => ({ type: "position", ...change }) as NodeChange); + result.current.onNodesChange(capturedMiddleware!(raw) as NodeChange[]); + }); + return { result, store, frame }; +} + +describe("useNodeDragAndSizeSync group drag (container/frame carrying members)", () => { + it("ends the gesture when a group with members is released - dragging, culling suspension and guides all clear", () => { + const { result, frame } = mountHook(); + + frame([{ id: "g", dragging: true, position: { x: 10, y: 3 } }]); + // Mid-drag: the gesture is live and the top-edge alignment published a + // guide - the precondition the release below must undo. + expect(result.current.draggingRef.current).toBe(true); + expect(result.current.dragActive).toBe(true); + expect(result.current.smartGuideLines).toHaveLength(1); + + frame([{ id: "g", dragging: true, position: { x: 20, y: 3 } }]); + // React Flow's own drag-STOP change: no dragging flag. + frame([{ id: "g", dragging: false, position: { x: 20, y: 3 } }]); + + expect(result.current.draggingRef.current).toBe(false); + expect(result.current.dragActive).toBe(false); + expect(result.current.smartGuideLines).toEqual([]); + // The carried members settle as not-dragging too, rather than keeping + // React Flow's per-node dragging flag set after the release. + const byId = new Map(result.current.nodesRef.current.map((n) => [n.id, n])); + expect(byId.get("m1")!.dragging).toBe(false); + expect(byId.get("m2")!.dragging).toBe(false); + }); + + it("still commits the group and every carried member's settled position in one batch on release", () => { + const { store, frame } = mountHook(); + + frame([{ id: "g", dragging: true, position: { x: 10, y: 3 } }]); + frame([{ id: "g", dragging: true, position: { x: 20, y: 3 } }]); + frame([{ id: "g", dragging: false, position: { x: 20, y: 3 } }]); + + // y snaps to the anchor's top edge (0), so the carried delta is (20, 0). + expect(store.moveNodes).toHaveBeenCalledTimes(1); + expect(store.moveNodes).toHaveBeenCalledWith([ + { id: "g", x: 20, y: 0 }, + { id: "m1", x: 40, y: 20 }, + { id: "m2", x: 120, y: 100 }, + ]); + }); + + it("ends a multi-select drag of a group plus an unrelated node the same way", () => { + const { result, store, frame } = mountHook(); + + frame([ + { id: "g", dragging: true, position: { x: 10, y: 40 } }, + { id: "anchor", dragging: true, position: { x: 510, y: 40 } }, + ]); + expect(result.current.draggingRef.current).toBe(true); + frame([ + { id: "g", dragging: false, position: { x: 10, y: 40 } }, + { id: "anchor", dragging: false, position: { x: 510, y: 40 } }, + ]); + + expect(result.current.draggingRef.current).toBe(false); + expect(result.current.dragActive).toBe(false); + expect(result.current.smartGuideLines).toEqual([]); + expect(store.moveNodes).toHaveBeenCalledTimes(1); + const committed = (store.moveNodes as ReturnType).mock.calls[0][0] as Array<{ id: string }>; + expect(committed.map((intent) => intent.id).sort()).toEqual(["anchor", "g", "m1", "m2"]); + }); +}); diff --git a/web_ui/src/app/canvas/useNodeDragAndSizeSync.ts b/web_ui/src/app/canvas/useNodeDragAndSizeSync.ts index 6507b229..375047ec 100644 --- a/web_ui/src/app/canvas/useNodeDragAndSizeSync.ts +++ b/web_ui/src/app/canvas/useNodeDragAndSizeSync.ts @@ -299,7 +299,11 @@ export function useNodeDragAndSizeSync( finalPosition = frame.position; frameGuides.push(...frame.guides); } - memberChanges.push(...applyGroupDragDelta(currentNodes, change.id, finalPosition)); + // Members inherit this change's own drag flag: on the drag-STOP + // change they must settle as not-dragging, or onNodesChange below + // re-opens the gesture this very batch closes (see + // applyGroupDragDelta's own doc). + memberChanges.push(...applyGroupDragDelta(currentNodes, change.id, finalPosition, Boolean(change.dragging))); return { ...change, position: finalPosition }; }); From 417fb435cb2d8611c6e6e9ab5fe9a5881064e885 Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:18:31 -0400 Subject: [PATCH 09/11] Save HTML node edits, and flush note and HTML drafts on Ctrl+S The HTML node's source textarea and preview lived only in component state, with no way to reach the backend. Off-viewport culling unmounts the card, so panning away discarded both, and because the stored source never changed, edits were also lost on save and reload. A new scene/setHtmlContent intent stores the source as an undoable command, like setNoteContent. The card commits on Render, on blur and on Ctrl+S, only when the source changed. It ignores the echo of its own commit so the cursor never jumps, but shows a change it did not make, such as an undo, when there is no uncommitted typing. Ctrl+S is exempt from the while-typing shortcut gate, so it saved the session without the draft in an open note or HTML editor. Both editors now push their draft ahead of the save. The new intent is classified as undoable in the undo gate and labelled "Edit HTML". Co-Authored-By: Claude --- backend/api/intents_nodes.py | 11 + backend/domain/commands.py | 1 + backend/domain/nodes_content.py | 16 ++ backend/tests/test_html_node_content.py | 155 ++++++++++++ tests/test_undo_classification_gate.py | 8 +- tests/undo_classification.py | 1 + web_ui/src/app/canvas/HtmlNodeView.test.tsx | 266 +++++++++++++++++++- web_ui/src/app/canvas/HtmlNodeView.tsx | 107 +++++++- web_ui/src/app/canvas/NoteNodeView.test.tsx | 32 +++ web_ui/src/app/canvas/NoteNodeView.tsx | 10 + web_ui/src/app/canvas/SceneCanvas.test.tsx | 11 + web_ui/src/app/canvas/SceneCanvas.tsx | 5 + web_ui/src/app/canvas/sceneStore.test.ts | 12 + web_ui/src/app/canvas/sceneStore.ts | 9 + 14 files changed, 616 insertions(+), 28 deletions(-) create mode 100644 backend/tests/test_html_node_content.py diff --git a/backend/api/intents_nodes.py b/backend/api/intents_nodes.py index 67d2a878..ef34c1ad 100644 --- a/backend/api/intents_nodes.py +++ b/backend/api/intents_nodes.py @@ -199,6 +199,16 @@ async def set_html_splitter_state(node_id, value): document.set_html_splitter_state(node_id, value) await publish_scene() + async def set_html_content(node_id, html_content): + # A source edit is document content, not view state like the + # splitter above, so it is recorded exactly as setNoteContent is: + # one undoable user command naming the node it rewrites in place. + document.record_command( + "setHtmlContent", "user", lambda: document.set_html_content(node_id, html_content), + node_ids=[node_id], + ) + await publish_scene() + async def add_image_node(x, y, image_bytes_base64, prompt, parent_id, mime_type="image/png"): # Unlike every prior wrapper, the WS intent transport is JSON, which # cannot carry raw bytes - the caller sends base64 text, decoded here @@ -338,6 +348,7 @@ async def remove_edges(edge_ids): bus.register_intent("scene", "addThinkingNode", add_thinking_node) bus.register_intent("scene", "addHtmlNode", add_html_node) bus.register_intent("scene", "setHtmlSplitterState", set_html_splitter_state) + bus.register_intent("scene", "setHtmlContent", set_html_content) bus.register_intent("scene", "addImageNode", add_image_node) bus.register_intent("scene", "addConversationNode", add_conversation_node) bus.register_intent("scene", "moveNode", move_node) diff --git a/backend/domain/commands.py b/backend/domain/commands.py index abc03249..a0752892 100644 --- a/backend/domain/commands.py +++ b/backend/domain/commands.py @@ -395,6 +395,7 @@ def _restore(live: dict, snapshot: dict) -> None: "synthesizeBranches": "Synthesize Branches", "generateImageReply": "Generate Image", "setNoteContent": "Edit Note", + "setHtmlContent": "Edit HTML", "setGroupLabel": "Rename Group", "setGroupColor": "Recolor", "toggleFrameLock": "Lock Frame", diff --git a/backend/domain/nodes_content.py b/backend/domain/nodes_content.py index e7fde511..ce2de776 100644 --- a/backend/domain/nodes_content.py +++ b/backend/domain/nodes_content.py @@ -193,6 +193,22 @@ def set_html_splitter_state(self, node_id: str, value: float) -> None: node = require_node(self.nodes, node_id, "html", HtmlState) node.state.html_splitter_state = float(value) + def set_html_content(self, node_id: str, html_content: str) -> None: + """Commits an HtmlViewNode's edited source. Without this the stored + source could only ever be what add_html_node was given: the card's + textarea edits lived in component state alone, so they vanished the + moment off-viewport culling unmounted the card and were never saved. + + Stored VERBATIM, exactly as add_html_node stores it (see its own doc + - the backend never parses or sanitizes the HTML). The title is left + as add_html_node set it: like set_note_content, + update_chat_node_content and the builderSetContent tool, an in-place + content mutator never recomputes a title post-creation, so an html + node's title cannot depend on which of those paths wrote it last. + html kind only, matching set_html_splitter_state's guard.""" + node = require_node(self.nodes, node_id, "html", HtmlState) + node.content = str(html_content) + # -- R6.1: notes ----------------------------------------------------------- # # Legacy canvas decorations. Notes are free-floating markdown sticky- diff --git a/backend/tests/test_html_node_content.py b/backend/tests/test_html_node_content.py new file mode 100644 index 00000000..658a7963 --- /dev/null +++ b/backend/tests/test_html_node_content.py @@ -0,0 +1,155 @@ +"""The HTML node's source-edit commit path: SceneDocument.set_html_content +and the scene/setHtmlContent intent that carries it. + +Before this path existed the HTML node's source textarea was local React +state only - the backend had add_html_node and nothing that could change the +stored source afterwards, so an edit was lost the moment the node unmounted +(off-viewport culling) and was never saved at all. These pin the domain +setter, the intent, its undo posture (same as setNoteContent's: a recorded +user edit), and the save/load round trip the edit has to survive. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +import backend.agents as agents_module # noqa: F401 - see test_canvas.py's own import-order note +from backend.agents import AgentDispatcher +from backend.canvas import SceneDocument, SceneError, register_canvas +from backend.composer import ComposerDocument +from backend.domain.model import HTML_TITLE_PREVIEW_LENGTH +from backend.events import SessionBus +from backend.notifications import NotificationState +from backend.session_load import restore_chat_into_document +from backend.session_save import build_chat_data +from backend.token_counter import register_token_counter + + +class _Recorder: + def __init__(self) -> None: + self.messages: list[dict] = [] + + async def send_json(self, data): + self.messages.append(data) + + def scene_publishes(self) -> int: + return sum(1 for m in self.messages if m["topic"] == "scene" and m["kind"] in ("state", "patch")) + + +class _FakeSettingsManager: + def get_enable_system_prompt(self): + return True + + def get_recipes(self): + return [] + + def set_recipes(self, recipes): + pass + + +def _make_bus(): + bus = SessionBus("html-content-test") + notifications = NotificationState() + bus.register_topic("notification", notifications.payload) + composer_document = ComposerDocument() + bus.register_topic("app-composer", composer_document.payload) + token_counter = register_token_counter(bus) + document = register_canvas( + bus, notifications, AgentDispatcher(_FakeSettingsManager()), composer_document, token_counter, + ) + recorder = _Recorder() + bus.attach(recorder) + return bus, document, recorder + + +def _html_doc(source: str = "

original

") -> tuple[SceneDocument, str, str]: + doc = SceneDocument() + chat = doc.add_chat_node(0, 0, "hi", True) + html = doc.add_html_node(0, 0, source, chat.id) + return doc, chat.id, html.id + + +def test_set_html_content_replaces_the_stored_source_and_leaves_the_title_alone(): + doc, _chat_id, html_id = _html_doc() + created_title = doc.nodes[html_id].title + assert created_title == "

original

"[:HTML_TITLE_PREVIEW_LENGTH] + + doc.set_html_content(html_id, "

edited

") + + node = doc.nodes[html_id] + assert node.content == "

edited

" + # Same posture as set_note_content, update_chat_node_content and the + # builderSetContent tool: an in-place content mutator never recomputes + # the title it was created with. + assert node.title == created_title + payload_node = next(n for n in doc.scene_payload()["nodes"] if n["id"] == html_id) + assert payload_node["content"] == "

edited

" + + +def test_set_html_content_stores_long_and_empty_source_verbatim(): + doc, _chat_id, html_id = _html_doc() + created_title = doc.nodes[html_id].title + long_source = "
" + "x" * (HTML_TITLE_PREVIEW_LENGTH * 2) + "
" + + doc.set_html_content(html_id, long_source) + assert doc.nodes[html_id].content == long_source + assert doc.nodes[html_id].title == created_title + + doc.set_html_content(html_id, "") + assert doc.nodes[html_id].content == "" + assert doc.nodes[html_id].title == created_title + + +def test_set_html_content_rejects_a_non_html_node_and_an_unknown_id(): + doc, chat_id, _html_id = _html_doc() + + with pytest.raises(SceneError): + doc.set_html_content(chat_id, "

nope

") + assert doc.nodes[chat_id].content == "hi" + + with pytest.raises(SceneError): + doc.set_html_content("does-not-exist", "

nope

") + + +def test_set_html_content_intent_mutates_publishes_and_is_undoable(): + async def run(): + bus, document, recorder = _make_bus() + chat_id = await bus.dispatch_intent("scene", "addChatNode", [0, 0, "hi", True]) + html_id = await bus.dispatch_intent("scene", "addHtmlNode", [0, 0, "

hi

", chat_id]) + recorder.messages.clear() + + await bus.dispatch_intent("scene", "setHtmlContent", [html_id, "

edited

"]) + assert document.nodes[html_id].content == "

edited

" + assert recorder.scene_publishes() == 1 + + # A recorded user edit, same as setNoteContent: Ctrl+Z restores the + # previous source. + await bus.dispatch_intent("scene", "undo", []) + assert document.nodes[html_id].content == "

hi

" + + # A non-html target and an unknown id both fail the dispatch, same + # as the sibling setHtmlSplitterState intent. + with pytest.raises(Exception): + await bus.dispatch_intent("scene", "setHtmlContent", [chat_id, "

nope

"]) + with pytest.raises(Exception): + await bus.dispatch_intent("scene", "setHtmlContent", ["does-not-exist", "

nope

"]) + assert document.nodes[chat_id].content == "hi" + + asyncio.run(run()) + + +def test_edited_html_source_survives_a_save_load_round_trip(): + doc, _chat_id, html_id = _html_doc() + doc.set_html_content(html_id, "
saved edit
") + + chat_data = build_chat_data(doc) + notes_data = chat_data.pop("notes_data") + pins_data = chat_data.pop("pins_data") + restored = SceneDocument() + restore_chat_into_document(restored, {"data": chat_data}, notes_data, pins_data) + + html_nodes = [n for n in restored.nodes.values() if n.kind == "html"] + assert len(html_nodes) == 1 + assert html_nodes[0].content == "
saved edit
" diff --git a/tests/test_undo_classification_gate.py b/tests/test_undo_classification_gate.py index c89e0ec2..95d8bc30 100644 --- a/tests/test_undo_classification_gate.py +++ b/tests/test_undo_classification_gate.py @@ -279,10 +279,12 @@ def test_the_scan_finds_the_real_population_of_registered_intents(): # when Review Lens added scene's own setCodeReviewPrUrl/ # fetchCodeReviewDiff/fetchCodeReviewDiffText/runCodeReview/ # cancelCodeReviewRequest/askCodeReviewQuestion/dismissCodeReviewFinding - # septet (backend/api/intents_code_review.py). + # septet (backend/api/intents_code_review.py), and 186 -> 187 when the + # HTML node got a real commit path for its edited source + # (scene/setHtmlContent, backend/api/intents_nodes.py). real = _collect_real_registrations() - assert len(real) == 186, ( - f"expected exactly 186 real registered intents, found {len(real)} - " + assert len(real) == 187, ( + f"expected exactly 187 real registered intents, found {len(real)} - " "either the scan broke, or the app's registered-intent surface " "genuinely changed and tests/undo_classification.py's own count " "comment (and this assertion) need a deliberate update alongside it" diff --git a/tests/undo_classification.py b/tests/undo_classification.py index 39ee1965..079081c4 100644 --- a/tests/undo_classification.py +++ b/tests/undo_classification.py @@ -146,6 +146,7 @@ class Classified: Classified("scene", "addThinkingNode", "A", "content: create"), Classified("scene", "addHtmlNode", "A", "content: create"), Classified("scene", "setHtmlSplitterState", "B", "view-state: per-node UI splitter position"), + Classified("scene", "setHtmlContent", "A", "content: text edit"), Classified("scene", "addImageNode", "A", "content: create"), Classified("scene", "addConversationNode", "A", "content: create"), Classified("scene", "moveNode", "A", "content: move"), diff --git a/web_ui/src/app/canvas/HtmlNodeView.test.tsx b/web_ui/src/app/canvas/HtmlNodeView.test.tsx index bba88484..e582e230 100644 --- a/web_ui/src/app/canvas/HtmlNodeView.test.tsx +++ b/web_ui/src/app/canvas/HtmlNodeView.test.tsx @@ -47,6 +47,7 @@ function renderHtmlNode(overrides: Partial = {}) { const onToggleCollapse = vi.fn(); const onDelete = vi.fn(); const onSplitterChange = vi.fn(); + const onSourceCommit = vi.fn(); const props = { id: "n0", selected: false, @@ -57,16 +58,27 @@ function renderHtmlNode(overrides: Partial = {}) { onToggleCollapse, onDelete, onSplitterChange, + onSourceCommit, ...overrides, }, } as unknown as NodeProps; - const { container } = render( + const { container, unmount, rerender } = render( , ); - return { onToggleCollapse, onDelete, onSplitterChange, container }; + // A later scene snapshot for the same mounted card: same callbacks, the + // given fields replaced - what toFlowNodes hands React Flow on a publish. + function rerenderWith(next: Partial) { + const nextProps = { ...props, data: { ...props.data, ...next } } as unknown as NodeProps; + rerender( + + + , + ); + } + return { onToggleCollapse, onDelete, onSplitterChange, onSourceCommit, container, unmount, rerenderWith }; } function getSplitter(container: HTMLElement): HTMLDivElement { @@ -390,6 +402,203 @@ describe("HtmlNodeView splitter (R6.3)", () => { }); }); +describe("HtmlNodeView source commit", () => { + // The source only ever lived in this card's local state, so an edit was + // lost whenever off-viewport culling unmounted the card, and was never + // saved. These pin the commit path that persists it. + it("clicking Render commits the typed source", async () => { + const user = userEvent.setup(); + const { container, onSourceCommit } = renderHtmlNode({ htmlContent: "

original

" }); + + fireEvent.change(getSourceTextarea(container), { target: { value: "

rendered edit

" } }); + expect(onSourceCommit).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Render" })); + expect(onSourceCommit).toHaveBeenCalledTimes(1); + expect(onSourceCommit).toHaveBeenCalledWith("

rendered edit

"); + }); + + it("the source textarea losing focus commits the typed source without touching the preview", () => { + const { container, onSourceCommit } = renderHtmlNode({ htmlContent: "

original

" }); + const textarea = getSourceTextarea(container); + + fireEvent.focus(textarea); + fireEvent.change(textarea, { target: { value: "

blurred edit

" } }); + fireEvent.blur(textarea); + + expect(onSourceCommit).toHaveBeenCalledTimes(1); + expect(onSourceCommit).toHaveBeenCalledWith("

blurred edit

"); + // Committing is not rendering: the R3.18 rule that only Render reaches + // the iframe still holds. + expect(getIframe(container).srcdoc).toBe(buildSandboxedHtmlDocument("

original

")); + }); + + it("commits nothing when the source is unchanged from the last committed value", async () => { + const user = userEvent.setup(); + const { container, onSourceCommit } = renderHtmlNode({ htmlContent: "

original

" }); + const textarea = getSourceTextarea(container); + + // Untouched: neither trigger sends the seed value back. + fireEvent.blur(textarea); + await user.click(screen.getByRole("button", { name: "Render" })); + expect(onSourceCommit).not.toHaveBeenCalled(); + + // Committed once by Render; the blur that follows has nothing new. + fireEvent.change(textarea, { target: { value: "

edit

" } }); + await user.click(screen.getByRole("button", { name: "Render" })); + fireEvent.blur(textarea); + expect(onSourceCommit).toHaveBeenCalledTimes(1); + + // Typing back to the ORIGINAL is a real change against what was last + // committed, so it is sent. + fireEvent.change(textarea, { target: { value: "

original

" } }); + fireEvent.blur(textarea); + expect(onSourceCommit).toHaveBeenCalledTimes(2); + expect(onSourceCommit).toHaveBeenLastCalledWith("

original

"); + }); + + it("a remount after a commit seeds both the source and the preview from the committed htmlContent", async () => { + const user = userEvent.setup(); + const first = renderHtmlNode({ htmlContent: "

original

" }); + fireEvent.change(getSourceTextarea(first.container), { target: { value: "

kept

" } }); + await user.click(screen.getByRole("button", { name: "Render" })); + expect(first.onSourceCommit).toHaveBeenCalledTimes(1); + const committed = first.onSourceCommit.mock.calls[0][0] as string; + // Off-viewport culling unmounts the card... + first.unmount(); + + // ...and it comes back carrying the server's echo of the commit. + const second = renderHtmlNode({ htmlContent: committed }); + expect(getSourceTextarea(second.container).value).toBe("

kept

"); + expect(getIframe(second.container).srcdoc).toBe(buildSandboxedHtmlDocument("

kept

")); + // Seeded from the committed value, so there is nothing new to send. + fireEvent.blur(getSourceTextarea(second.container)); + expect(second.onSourceCommit).not.toHaveBeenCalled(); + }); + + it("Ctrl+S in the source textarea commits the typed source before the app-level save handler sees the key", () => { + // Save is exempt from the while-typing shortcut gate, so Ctrl+S reaches + // App's document-level handler (saveChat) with no blur and no Render. + // The commit has to go out first or the saved session lacks the edit. + const order: string[] = []; + const onSourceCommit = vi.fn(() => { + order.push("commit"); + }); + const { container } = renderHtmlNode({ htmlContent: "

original

", onSourceCommit }); + const textarea = getSourceTextarea(container); + const onDocumentKeyDown = () => { + order.push("document keydown"); + }; + document.addEventListener("keydown", onDocumentKeyDown); + try { + fireEvent.change(textarea, { target: { value: "

saved edit

" } }); + // Neither plain typing nor a different shortcut flushes anything. + fireEvent.keyDown(textarea, { key: "s" }); + fireEvent.keyDown(textarea, { key: "z", ctrlKey: true }); + expect(onSourceCommit).not.toHaveBeenCalled(); + order.length = 0; + + fireEvent.keyDown(textarea, { key: "s", ctrlKey: true }); + } finally { + document.removeEventListener("keydown", onDocumentKeyDown); + } + + expect(onSourceCommit).toHaveBeenCalledTimes(1); + expect(onSourceCommit).toHaveBeenCalledWith("

saved edit

"); + expect(order).toEqual(["commit", "document keydown"]); + // Already committed, so the blur when focus later leaves sends nothing. + fireEvent.blur(textarea); + expect(onSourceCommit).toHaveBeenCalledTimes(1); + }); + + it("an undo of a committed edit (htmlContent going back while mounted) shows the restored source in the textarea and preview", async () => { + const user = userEvent.setup(); + const { container, onSourceCommit, rerenderWith } = renderHtmlNode({ htmlContent: "

original

" }); + + fireEvent.change(getSourceTextarea(container), { target: { value: "

edit

" } }); + await user.click(screen.getByRole("button", { name: "Render" })); + expect(onSourceCommit).toHaveBeenCalledWith("

edit

"); + rerenderWith({ htmlContent: "

edit

" }); // the commit's own echo + + // Ctrl+Z / the AppBar's Undo: the server goes back to the original. + rerenderWith({ htmlContent: "

original

" }); + expect(getSourceTextarea(container).value).toBe("

original

"); + expect(getIframe(container).srcdoc).toBe(buildSandboxedHtmlDocument("

original

")); + + // The card now agrees with the server, so the untouched source sends + // nothing - and re-making the same edit is a real change again. + fireEvent.blur(getSourceTextarea(container)); + expect(onSourceCommit).toHaveBeenCalledTimes(1); + fireEvent.change(getSourceTextarea(container), { target: { value: "

edit

" } }); + fireEvent.blur(getSourceTextarea(container)); + expect(onSourceCommit).toHaveBeenCalledTimes(2); + expect(onSourceCommit).toHaveBeenLastCalledWith("

edit

"); + }); + + it("the echo of the card's own commit arriving mid-typing leaves the textarea and preview alone", () => { + const { container, onSourceCommit, rerenderWith } = renderHtmlNode({ htmlContent: "

original

" }); + const textarea = getSourceTextarea(container); + + fireEvent.change(textarea, { target: { value: "

first

" } }); + fireEvent.blur(textarea); + expect(onSourceCommit).toHaveBeenCalledWith("

first

"); + // The user is back in the field and typing when the echo lands. + fireEvent.change(textarea, { target: { value: "

first, then more

" } }); + rerenderWith({ htmlContent: "

first

" }); + + expect(getSourceTextarea(container).value).toBe("

first, then more

"); + expect(getIframe(container).srcdoc).toBe(buildSandboxedHtmlDocument("

original

")); + fireEvent.blur(getSourceTextarea(container)); + expect(onSourceCommit).toHaveBeenCalledTimes(2); + expect(onSourceCommit).toHaveBeenLastCalledWith("

first, then more

"); + }); + + it("a late echo of an earlier commit never reverts a newer committed edit", () => { + const { container, onSourceCommit, rerenderWith } = renderHtmlNode({ htmlContent: "

original

" }); + const textarea = getSourceTextarea(container); + + fireEvent.change(textarea, { target: { value: "

one

" } }); + fireEvent.blur(textarea); + fireEvent.change(textarea, { target: { value: "

two

" } }); + fireEvent.blur(textarea); + expect(onSourceCommit).toHaveBeenCalledTimes(2); + + // Both commits are in flight; the first one's echo lands now. + rerenderWith({ htmlContent: "

one

" }); + expect(getSourceTextarea(container).value).toBe("

two

"); + rerenderWith({ htmlContent: "

two

" }); + expect(getSourceTextarea(container).value).toBe("

two

"); + expect(getIframe(container).srcdoc).toBe(buildSandboxedHtmlDocument("

original

")); + fireEvent.blur(getSourceTextarea(container)); + expect(onSourceCommit).toHaveBeenCalledTimes(2); + }); + + it("a remount that beats the commit's echo picks the echo up when it lands", () => { + // The card committed, was culled and came back before the server's echo + // did, so it seeded the pre-edit value; an echo of a commit this + // instance never sent is a real change to show, not one to ignore. + const { container, rerenderWith } = renderHtmlNode({ htmlContent: "

pre-edit

" }); + rerenderWith({ htmlContent: "

committed before the remount

" }); + + expect(getSourceTextarea(container).value).toBe("

committed before the remount

"); + expect(getIframe(container).srcdoc).toBe(buildSandboxedHtmlDocument("

committed before the remount

")); + }); + + it("a change the card did not make, arriving over uncommitted typing, never overwrites the typing - the next commit wins", () => { + const { container, onSourceCommit, rerenderWith } = renderHtmlNode({ htmlContent: "

original

" }); + const textarea = getSourceTextarea(container); + + fireEvent.change(textarea, { target: { value: "

typing

" } }); + rerenderWith({ htmlContent: "

agent wrote this

" }); + + expect(getSourceTextarea(container).value).toBe("

typing

"); + expect(getIframe(container).srcdoc).toBe(buildSandboxedHtmlDocument("

original

")); + fireEvent.blur(getSourceTextarea(container)); + expect(onSourceCommit).toHaveBeenCalledTimes(1); + expect(onSourceCommit).toHaveBeenCalledWith("

typing

"); + }); +}); + describe("clampSplitterValue", () => { it("passes values already inside [HTML_SPLIT_MIN, HTML_SPLIT_MAX] through unchanged", () => { expect(clampSplitterValue(0.5)).toBe(0.5); @@ -464,6 +673,7 @@ describe("HtmlNodeView React.memo comparator (ADR-011 stage 11.1)", () => { onToggleCollapse: vi.fn(), onDelete: vi.fn(), onSplitterChange: vi.fn(), + onSourceCommit: vi.fn(), ...overrides, }; return { id: "n0", selected: false, data } as unknown as NodeProps; @@ -491,13 +701,12 @@ describe("HtmlNodeView React.memo comparator (ADR-011 stage 11.1)", () => { expect(lodVisibilityCalls.count).toBe(1); }); - it("skips re-rendering when only `htmlContent`/`htmlSplitterState` (seed-once fields this view never re-reads) change", () => { - // Pins the deliberate omission documented on htmlNodeDataAreEqual: both - // fields only seed local state via a useState initializer on mount and - // are never read again by any JSX/effect/closure - so a change to either - // alone must never re-render (and, per the "typing does NOT change the - // iframe" test above, could not visibly affect anything even if it did). - const props = baseHtmlProps({ htmlContent: "

original

", htmlSplitterState: 0.5 }); + it("skips re-rendering when only `htmlSplitterState` (a seed-once field this view never re-reads) changes", () => { + // Pins the deliberate omission documented on htmlNodeDataAreEqual: the + // split only seeds local state via a useState initializer on mount and + // is never read again by any JSX/effect/closure, so a change to it alone + // must never re-render. + const props = baseHtmlProps({ htmlSplitterState: 0.5 }); const { rerender } = render( @@ -507,12 +716,28 @@ describe("HtmlNodeView React.memo comparator (ADR-011 stage 11.1)", () => { rerender( - different

", htmlSplitterState: 0.2 } }} - /> + +
, + ); + expect(lodVisibilityCalls.count).toBe(1); + }); + + it("re-renders when `htmlContent` changes, so a change the card did not make (an undo) can reach it", () => { + const props = baseHtmlProps({ htmlContent: "

original

" }); + const { container, rerender } = render( + + , ); expect(lodVisibilityCalls.count).toBe(1); + + rerender( + + different

" } }} /> +
, + ); + expect(lodVisibilityCalls.count).toBeGreaterThan(1); + expect(getSourceTextarea(container).value).toBe("

different

"); }); it("re-renders when `isCollapsed` (a field the view reads) changes", () => { @@ -553,4 +778,21 @@ describe("HtmlNodeView React.memo comparator (ADR-011 stage 11.1)", () => { ); expect(lodVisibilityCalls.count).toBe(2); }); + + it("re-renders when `onSourceCommit` is rebound, so the Render/blur handlers never hold a stale commit closure", () => { + const props = baseHtmlProps(); + const { rerender } = render( + + + , + ); + expect(lodVisibilityCalls.count).toBe(1); + + rerender( + + + , + ); + expect(lodVisibilityCalls.count).toBe(2); + }); }); diff --git a/web_ui/src/app/canvas/HtmlNodeView.tsx b/web_ui/src/app/canvas/HtmlNodeView.tsx index a6142fcc..9b203d4c 100644 --- a/web_ui/src/app/canvas/HtmlNodeView.tsx +++ b/web_ui/src/app/canvas/HtmlNodeView.tsx @@ -1,5 +1,6 @@ import type { Node, NodeProps } from "@xyflow/react"; import { memo, useEffect, useRef, useState } from "react"; +import { resolveShortcut } from "../chrome/shortcuts"; import { HTML_SPLIT_DEFAULT, HTML_SPLIT_MAX, @@ -67,6 +68,11 @@ export interface HtmlNodeData extends Record { onToggleCollapse: () => void; onDelete: () => void; onSplitterChange: (value: number) => void; + // Persists the edited source (scene/setHtmlContent). Called on Render, on + // the source textarea's blur and on the save key pressed inside it, and + // only when the source differs from what the server holds - see + // commitSource below. + onSourceCommit: (content: string) => void; } export type HtmlFlowNode = Node; @@ -166,12 +172,75 @@ function HtmlNodeViewImpl({ data, selected }: NodeProps) { // Two separate pieces of local state, per the R3.18 spec: `sourceText` // tracks every keystroke in the textarea (fine - it never touches the // iframe), while `renderedDoc` is only ever reassigned by the Render - // button's onClick below, via buildSandboxedHtmlDocument. The iframe's - // srcDoc is bound to `renderedDoc` alone, never to `sourceText` - typing - // literally cannot reach the iframe through any path in this component. + // button's onClick below, via buildSandboxedHtmlDocument, or reseeded + // from a server value the card did not write (see prevHtmlContent below). + // The iframe's srcDoc is bound to `renderedDoc` alone, never to + // `sourceText` - typing literally cannot reach the iframe through any + // path in this component. const [sourceText, setSourceText] = useState(data.htmlContent); const [renderedDoc, setRenderedDoc] = useState(() => buildSandboxedHtmlDocument(data.htmlContent)); + // Off-viewport culling genuinely unmounts this card, so an edit that + // never reached the backend is lost on the next remount (and on save). + // commitSource persists it on the "the user is done with this edit" + // moments: Render, the textarea losing focus, and the save key pressed + // inside it (see onSourceKeyDown). Never per keystroke, so the server's + // echo of a commit cannot race the cursor. + // + // `committedSource` is what this card believes the server holds once its + // own in-flight commits land, so a blur after Render, or a Render of + // untouched source, sends nothing. `pendingCommits` lists, oldest first, + // the values this card sent that have not come back as data.htmlContent + // yet. + const [committedSource, setCommittedSource] = useState(data.htmlContent); + const [pendingCommits, setPendingCommits] = useState([]); + function commitSource() { + if (sourceText === committedSource) return; + setCommittedSource(sourceText); + setPendingCommits((pending) => [...pending, sourceText]); + data.onSourceCommit(sourceText); + } + + // A new data.htmlContent is either the echo of one of this card's own + // commits or a change the card did not make (an undo/redo of a + // setHtmlContent, an agent's write, or the echo of a commit sent by an + // earlier mount of this card). The echo must never touch the textarea: + // the user may already be typing past it. Anything else is shown - the + // card otherwise keeps displaying text the server no longer holds, and a + // later save writes what the user is not looking at - but only when there + // is no uncommitted typing to lose; typing over it simply wins on the + // next commit, since committedSource now names the server's value. + // Matching an echo drops every older pending entry with it (the scene bus + // coalesces publishes, so an intermediate value may never be seen), and + // a change the card did not make clears the list: publishes arrive in + // server order, so whatever arrives after it is the server's real state. + // Adjusted during render, React's documented "adjusting state when a prop + // changes" pattern, so the reseed and the new prop land in one render. + const [prevHtmlContent, setPrevHtmlContent] = useState(data.htmlContent); + if (data.htmlContent !== prevHtmlContent) { + setPrevHtmlContent(data.htmlContent); + const echoIndex = pendingCommits.indexOf(data.htmlContent); + if (echoIndex !== -1) { + setPendingCommits(pendingCommits.slice(echoIndex + 1)); + } else { + setPendingCommits([]); + setCommittedSource(data.htmlContent); + if (sourceText === committedSource) { + setSourceText(data.htmlContent); + setRenderedDoc(buildSandboxedHtmlDocument(data.htmlContent)); + } + } + } + + // Save is exempt from the while-typing shortcut gate (see shortcuts.ts), + // so Ctrl+S here reaches App's document-level handler with no blur and no + // Render. React's root listener runs before that document listener, so + // committing here sends setHtmlContent ahead of saveChat and the saved + // session holds what the user sees. The event is left to propagate. + function onSourceKeyDown(event: React.KeyboardEvent) { + if (resolveShortcut(event) === "save-chat") commitSource(); + } + // R6.3: the Source/Preview split - restored from data.htmlSplitterState on // mount (see the component-level useState initializer), then dragged // locally via onSplitterPointerDown below. splitterTimerRef carries the @@ -258,6 +327,8 @@ function HtmlNodeViewImpl({ data, selected }: NodeProps) { style={{ height: sourceHeightPx }} value={sourceText} onChange={(event) => setSourceText(event.target.value)} + onBlur={commitSource} + onKeyDown={onSourceKeyDown} spellCheck={false} /> @@ -277,7 +348,10 @@ function HtmlNodeViewImpl({ data, selected }: NodeProps) { @@ -312,24 +386,31 @@ function HtmlNodeViewImpl({ data, selected }: NodeProps) { /** ADR-011 stage 11.1: every prop this view actually reads, compared - it * never destructures `id`, so it is intentionally absent (this instance never * receives a changed `id` without React Flow remounting it under a new key - * anyway). `htmlContent` and `htmlSplitterState` are intentionally OMITTED: - * each seeds local state ONCE via a `useState` initializer on mount - * (`sourceText`/`renderedDoc` and `splitterValue` respectively) and is never + * anyway). `htmlSplitterState` is intentionally OMITTED: it seeds + * `splitterValue` ONCE via a `useState` initializer on mount and is never * read again afterward by any JSX, effect, or event-handler closure - so - * comparing them would only cause spurious re-renders that produce + * comparing it would only cause spurious re-renders that produce * byte-identical output, never fix a missed one (same reasoning * WebResearchNodeView's own comparator applies to researchActiveSourceId). + * `htmlContent` IS compared: the view reads it on every render to tell the + * echo of its own commit (left alone, so it never fights the cursor) from a + * change it did not make, such as an undo (shown) - see the prevHtmlContent + * block in the component. * Everything else - `isCollapsed`, and every callback, including - * `onSplitterChange`, which IS captured fresh into a real event-handler - * closure on every render (onSplitterPointerDown's onPointerUp) and so must - * be compared, not just fields that show up directly in JSX - is a primitive - * or stable callback reference, so `===` is correct for all of them. */ + * `onSplitterChange` and `onSourceCommit`, which ARE captured fresh into real + * event-handler closures on every render (onSplitterPointerDown's + * onPointerUp; the Render button and textarea blur via commitSource) and so + * must be compared, not just fields that show up directly in JSX - is a + * primitive or stable callback reference, so `===` is correct for all of + * them. */ function htmlNodeDataAreEqual(prev: HtmlNodeData, next: HtmlNodeData): boolean { return ( + prev.htmlContent === next.htmlContent && prev.isCollapsed === next.isCollapsed && prev.onToggleCollapse === next.onToggleCollapse && prev.onDelete === next.onDelete && - prev.onSplitterChange === next.onSplitterChange + prev.onSplitterChange === next.onSplitterChange && + prev.onSourceCommit === next.onSourceCommit ); } diff --git a/web_ui/src/app/canvas/NoteNodeView.test.tsx b/web_ui/src/app/canvas/NoteNodeView.test.tsx index 93b160a6..c302ba43 100644 --- a/web_ui/src/app/canvas/NoteNodeView.test.tsx +++ b/web_ui/src/app/canvas/NoteNodeView.test.tsx @@ -87,6 +87,38 @@ describe("NoteNodeView", () => { expect(screen.queryByRole("textbox")).toBeNull(); }); + it("Ctrl+S while editing pushes the draft before the app-level save handler, and stays in edit mode", () => { + // Save is exempt from the while-typing shortcut gate, so Ctrl+S reaches + // App's document-level handler (saveChat) with no blur. The draft has + // to go out first or the saved session lacks the edit. + const { container, onSetContent } = renderNoteNode({ content: "original" }); + fireEvent.doubleClick(container.querySelector(".note-node-content")!); + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + const order: string[] = []; + onSetContent.mockImplementation(() => { + order.push("commit"); + }); + const onDocumentKeyDown = () => { + order.push("document keydown"); + }; + document.addEventListener("keydown", onDocumentKeyDown); + try { + fireEvent.change(textarea, { target: { value: "saved edit" } }); + fireEvent.keyDown(textarea, { key: "s" }); + expect(onSetContent).not.toHaveBeenCalled(); + order.length = 0; + + fireEvent.keyDown(textarea, { key: "s", ctrlKey: true }); + } finally { + document.removeEventListener("keydown", onDocumentKeyDown); + } + + expect(onSetContent).toHaveBeenCalledTimes(1); + expect(onSetContent).toHaveBeenCalledWith("saved edit"); + expect(order).toEqual(["commit", "document keydown"]); + expect(screen.getByRole("textbox")).toBeTruthy(); + }); + it("Escape reverts to the last-committed content and exits edit mode WITHOUT committing", () => { const { container, onSetContent } = renderNoteNode({ content: "original" }); fireEvent.doubleClick(container.querySelector(".note-node-content")!); diff --git a/web_ui/src/app/canvas/NoteNodeView.tsx b/web_ui/src/app/canvas/NoteNodeView.tsx index 45aa3dc9..1a3528d2 100644 --- a/web_ui/src/app/canvas/NoteNodeView.tsx +++ b/web_ui/src/app/canvas/NoteNodeView.tsx @@ -1,5 +1,6 @@ import { type Node, type NodeProps } from "@xyflow/react"; import { memo, useEffect, useRef, useState } from "react"; +import { resolveShortcut } from "../chrome/shortcuts"; import { GroupColorPicker } from "./GroupColorPicker"; import { NodeMarkdown } from "./NodeMarkdown"; import { NodeShell } from "./NodeShell"; @@ -94,6 +95,15 @@ function NoteNodeViewImpl({ data, selected }: NodeProps) { skipBlurRef.current = true; setDraft(data.content); setEditing(false); + } else if (resolveShortcut(event) === "save-chat") { + // Save is exempt from the while-typing shortcut gate (see + // shortcuts.ts), so Ctrl+S reaches App's document-level handler with + // no blur. React's root listener runs before that document listener, + // so pushing the draft here sends setNoteContent ahead of saveChat and + // the saved session holds the note as it reads on screen. Editing + // stays open; the eventual blur re-sends the same text, which the + // backend records as no change. + data.onSetContent(draft); } // Enter is a plain newline here (multi-line markdown body) - only blur // commits, matching the spec's "onBlur commits ... Escape reverts" diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx index 96fabce8..2758cda9 100644 --- a/web_ui/src/app/canvas/SceneCanvas.test.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx @@ -2131,6 +2131,17 @@ describe("toFlowNodes (R6.3 html node splitter state)", () => { expect(spy).toHaveBeenCalledWith("html-1", 0.6); }); + it("onSourceCommit calls setHtmlContent with this node's own id and the committed source", () => { + const scene = baseScene({ nodes: [baseNode({ id: "html-1", kind: "html", content: "

hi

" })], edges: [] }); + const store = makeStore(); + const spy = vi.spyOn(store, "setHtmlContent"); + + const flowNodes = toFlowNodes(scene, store); + const data = flowNodes.find((n) => n.id === "html-1")!.data as { onSourceCommit: (source: string) => void }; + data.onSourceCommit("

edited

"); + expect(spy).toHaveBeenCalledWith("html-1", "

edited

"); + }); + // ADR-003 stage 3.3 (C9) review-fix: the "defaults ... to null" test above // never actually exercises SceneCanvas.tsx's `n.htmlSplitterState ?? // null` normalization - baseNode()'s own default is already `null`, so diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx index 45b844e1..7a110af1 100644 --- a/web_ui/src/app/canvas/SceneCanvas.tsx +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -752,6 +752,11 @@ function makeHtmlFns(id: string, liveRef: { current: DispatcherLive }) { // HtmlNodeView (restore) and reported (debounced) via the // setHtmlSplitterState intent once a drag settles. onSplitterChange: (value: number) => liveRef.current.store.setHtmlSplitterState(id, value), + // The edited source, committed by HtmlNodeView on Render and on the + // textarea's blur. Persisting it is what lets a remount (off-viewport + // culling) or a session load re-seed the card with the user's edit + // instead of the source the node was created with. + onSourceCommit: (content: string) => liveRef.current.store.setHtmlContent(id, content), }; } diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts index 39c6deff..3844e2a5 100644 --- a/web_ui/src/app/canvas/sceneStore.test.ts +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -1472,6 +1472,18 @@ describe("SceneStore", () => { expect(intents).toEqual([{ topic: "scene", intent: "setHtmlSplitterState", args: ["html-1", 0.4] }]); }); + it("setHtmlContent sends the scene-topic setHtmlContent intent with [nodeId, content], queueable like setNoteContent", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.setHtmlContent("html-1", "

edited

"); + expect(intents).toEqual([{ topic: "scene", intent: "setHtmlContent", args: ["html-1", "

edited

"] }]); + // A last-write-wins text field, so it may wait out a reconnect in the + // offline queue rather than being refused. + expect(transport.fireIntent).toHaveBeenCalledWith( + "scene", "setHtmlContent", ["html-1", "

edited

"], undefined, true, + ); + }); + it("setChatScrollValue sends the scene-topic setChatScrollValue intent with [nodeId, value]", () => { const { transport, intents } = makeFakeTransport(); const store = new SceneStore(transport); diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts index 688a920a..7d643e28 100644 --- a/web_ui/src/app/canvas/sceneStore.ts +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -1495,6 +1495,15 @@ export class SceneStore { this.transport.fireIntent("scene", "setHtmlSplitterState", [nodeId, value], undefined, true); } + // The HTML node's edited source - unlike the view-state setters around it, + // real document content (an undoable command server-side, like + // setNoteContent). Same fire-and-forget, queueable posture as + // setNoteContent: a last-write-wins text field, so the offline queue may + // hold it across a reconnect. + setHtmlContent(nodeId: string, content: string): void { + this.transport.fireIntent("scene", "setHtmlContent", [nodeId, content], undefined, true); + } + setChatScrollValue(nodeId: string, value: number): void { this.transport.fireIntent("scene", "setChatScrollValue", [nodeId, value], undefined, true); } From 5cf0661b342417a77fa6277da2a4b66b57391c1d Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:43:21 -0400 Subject: [PATCH 10/11] Bump Python and npm dependencies past new security advisories Both dependency audits now fail every PR, main's lockfiles included. pip-audit: brotli 1.2.0, certifi 2026.6.17, pillow 12.3.0, pypdf 6.16.1 and urllib3 2.7.0 have published advisories. Regenerated with the lockfile's own command, upgrading only those five (pip-compile --generate-hashes --upgrade-package ...): brotli 1.2.1, certifi 2026.10.23, pillow 13.0.0, pypdf 6.21.2, urllib3 2.8.0. Pillow is a major bump; the full suite passes on it unchanged. npm audit (high and above): brace-expansion, fast-uri, source-map-js, undici, vite and zod. All six are fixed within their current major ranges by npm audit fix, so only package-lock.json changes; vite stays on 6.x. The remaining low and moderate advisories need breaking upgrades and are below the gate. pip-audit: no known vulnerabilities. npm audit: no high or critical. Full backend suite 3,493 passed (coverage 86.6%); npm run check passed, 2,250 tests, bundle sizes unchanged. Co-Authored-By: Claude --- requirements.txt | 435 +++++++++++++++++++++------------------ web_ui/package-lock.json | 83 ++++---- 2 files changed, 278 insertions(+), 240 deletions(-) diff --git a/requirements.txt b/requirements.txt index a2dd9abc..a1f566d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,111 +32,154 @@ bottle==0.13.4 \ --hash=sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e \ --hash=sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47 # via pywebview -brotli==1.2.0 \ - --hash=sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24 \ - --hash=sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f \ - --hash=sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4 \ - --hash=sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de \ - --hash=sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c \ - --hash=sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470 \ - --hash=sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744 \ - --hash=sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a \ - --hash=sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2 \ - --hash=sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502 \ - --hash=sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937 \ - --hash=sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7 \ - --hash=sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca \ - --hash=sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6 \ - --hash=sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17 \ - --hash=sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc \ - --hash=sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b \ - --hash=sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971 \ - --hash=sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe \ - --hash=sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d \ - --hash=sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac \ - --hash=sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd \ - --hash=sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84 \ - --hash=sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e \ - --hash=sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18 \ - --hash=sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a \ - --hash=sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947 \ - --hash=sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a \ - --hash=sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0 \ - --hash=sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46 \ - --hash=sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48 \ - --hash=sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8 \ - --hash=sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5 \ - --hash=sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3 \ - --hash=sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a \ - --hash=sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6 \ - --hash=sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64 \ - --hash=sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c \ - --hash=sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984 \ - --hash=sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21 \ - --hash=sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5 \ - --hash=sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a \ - --hash=sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b \ - --hash=sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7 \ - --hash=sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b \ - --hash=sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982 \ - --hash=sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f \ - --hash=sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b \ - --hash=sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84 \ - --hash=sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518 \ - --hash=sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d \ - --hash=sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae \ - --hash=sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16 \ - --hash=sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a \ - --hash=sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f \ - --hash=sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1 \ - --hash=sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190 \ - --hash=sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7 \ - --hash=sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e \ - --hash=sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e \ - --hash=sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea \ - --hash=sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8 \ - --hash=sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3 \ - --hash=sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab \ - --hash=sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526 \ - --hash=sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1 \ - --hash=sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92 \ - --hash=sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12 \ - --hash=sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03 \ - --hash=sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8 \ - --hash=sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d \ - --hash=sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28 \ - --hash=sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036 \ - --hash=sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997 \ - --hash=sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44 \ - --hash=sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8 \ - --hash=sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb \ - --hash=sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533 \ - --hash=sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8 \ - --hash=sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2 \ - --hash=sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69 \ - --hash=sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96 \ - --hash=sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49 \ - --hash=sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f \ - --hash=sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63 \ - --hash=sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f \ - --hash=sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888 \ - --hash=sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7 \ - --hash=sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a \ - --hash=sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3 \ - --hash=sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8 \ - --hash=sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990 \ - --hash=sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e \ - --hash=sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161 \ - --hash=sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675 \ - --hash=sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196 \ - --hash=sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c \ - --hash=sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13 \ - --hash=sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361 \ - --hash=sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d +brotli==1.2.1 \ + --hash=sha256:0023348c448140a6ea71615eba331835c971d38ae8acf8a89b4e963b1f38f131 \ + --hash=sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399 \ + --hash=sha256:00c9a4262dd81cdc32968f8bc2482c735ad238010d95b49698f1b788c359c7a4 \ + --hash=sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de \ + --hash=sha256:035f3bf861328d8b7fcb61f4a976f9913027158e5666116733d59ffc65f2745e \ + --hash=sha256:06a22fcbcae059c0679098232779095fc50a735a3fea02db5c79cab10b84d877 \ + --hash=sha256:09a2f112a638aca15a88f298571788795fc45d62f2bca7f3870c1152153ef399 \ + --hash=sha256:09b560859a04d112d206639c7e444161ee2f413098aa6de11d43daf3ce883d24 \ + --hash=sha256:0a30e5df5ba985b67f85e13dfd7e5a68e186d8d9b5cff7d9a8904832bb413e81 \ + --hash=sha256:0c5c6144aa4b4585e4e0b3595f9fb2743a2e32650bce4b2669a0e809b3e7bbbd \ + --hash=sha256:103b4762941760ae245d8d0d50e3ee4a7e034fcd786f122d177f76d6afc90c63 \ + --hash=sha256:104d3504678db9172de523bdd8e6ab8d3643067fbde18e5b89476f405b64cf25 \ + --hash=sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08 \ + --hash=sha256:1329c80daa60752f1d94f507479504921e358e71746c5fce9b55b3d51ac038bc \ + --hash=sha256:1624ca7006d2776cc8f0313e62d63aef9d3720b66f0d24e4439f5d754488e001 \ + --hash=sha256:1880db6241276e191d3e34a6e4eea708c6629103c396f0ea21e75b0ce63a9a2c \ + --hash=sha256:1f5d8c6e68555b82692f8180ee646cd1752aa9ab98daf68769a7c56a6e8d7af7 \ + --hash=sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109 \ + --hash=sha256:228abf241b39925450ea5046cbd8bc88ae1b91cd229037b0e194409df4ef2f4a \ + --hash=sha256:23f0d23995100458d17f3bf5ce1e959fe46df894b472063a74266f7adeb2e72e \ + --hash=sha256:24a3f765167e6ecac3d85a9fe3da857a068097cf44abc08bd67c5681d8251bca \ + --hash=sha256:25d7c19ea92df4be1358126b6712209ac58229d17af915397d1510f9efe0411d \ + --hash=sha256:264fd5b200f6f6170cb551a4b9fcaabf8aa846ddaefe72db4d5f49f9d3743df9 \ + --hash=sha256:2df1678c2c25db2f669c613fe8b4e587d0dd24c6acf7746bad13d6e87e4b051d \ + --hash=sha256:304ee5bf4f85b719f6a274fa0b50d86b39b5dd1219f4cc61410d4eae315757a9 \ + --hash=sha256:32567555e2e0040c596beb7f5f2ef69da6bf224aee4617c2a52099248dd3e243 \ + --hash=sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7 \ + --hash=sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132 \ + --hash=sha256:390f5e174ac2296dafd047d0785591e61194ff3df11ad43abd2fddb0e750eff5 \ + --hash=sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd \ + --hash=sha256:3b7df499157279a4d5d83ab86bd328c58c9ccfc48f5de45d4e791d3234a54dff \ + --hash=sha256:4092027deb7313156d2b601fb0e3637cbdf9d598f5bd57dc57348432967ecd31 \ + --hash=sha256:457f011c135263900805b4bf0dd290724d277785656c32e4f5fefe4a1feede1e \ + --hash=sha256:4583e4d11d40fa4a05b7d81cd822008d65bce755dd78c49945b5a98272953759 \ + --hash=sha256:461daaef60db39e407a6a6954a2b4804170db56c6212c009a6cdadbdd12d7a9b \ + --hash=sha256:4839f1a1ec3a95671238b2a165eba7a0a823090913ab4fc48aa03dc6f666dd02 \ + --hash=sha256:49de36a626557629db1f9b1eb57e7d88f3815a42373ff7f9d8b87c1da120687e \ + --hash=sha256:4acd709bfe362c2ed80da311d195654467110bc747de1484c731304810fd20e5 \ + --hash=sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61 \ + --hash=sha256:4d47a9f0ba5ee608f3f3a9c942dcd244443068ae5b0d90183dd396dea02236e4 \ + --hash=sha256:4e89a05511f8538911d98566f6ddd0883f28a5ab35fefbd70c036ab630feae1b \ + --hash=sha256:4f94a73b5f30b8bb36ac684f2b6a6e9bdc58a9b4bfe1658592727369443bce93 \ + --hash=sha256:50248e2f05c8335089b9c2a80ebb4a8b5833a3581a433438b523e2341f45e681 \ + --hash=sha256:509ee1058937d80d88b33aa893ca46e1925779d928181916bd60bda3a8738687 \ + --hash=sha256:59ca34c7a83d7a3b842247183c426f891a95a8f57db41b7aac92195c51e8a83a \ + --hash=sha256:59ec359a56f44277ad424fb4b7cf1a0ae2fd855b35ba8f15dcfbcabb3fb51aeb \ + --hash=sha256:5c77d41984e388c9f393da124394af941ef757fb33bb6f1eb72e8167fc557489 \ + --hash=sha256:5da5cb778a7fc889f72bd2238ddf39b9f7e8b0979ccefd760380f1c24bd9f33b \ + --hash=sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6 \ + --hash=sha256:624240d21823e1805425c155fe8b35b4124e0a837eb8ea271bd0a54caeac4ef6 \ + --hash=sha256:6532d2c58018121a9eb5882656cf862cdbbd853600cb5fbccac59bcff14f66b0 \ + --hash=sha256:66f171af80744c06f6b4e5b01fe6e40c70cc4e17570888746a281422fed83fc0 \ + --hash=sha256:67f4dceb22ce3a742751cee849644449b169684699aa548e77d771de304e963c \ + --hash=sha256:6b3c3d5794212a54434b4856f808f7bdf7ad48eacadaadb5b2346fc9a639365a \ + --hash=sha256:6b885b6b1a3b01c5980dddeb2d9dccbc5a5dc0f255ea15904bf585b73ef375a1 \ + --hash=sha256:6bd29ab84bb8972370cacd310e8a91651f54476d01de1cd1a4cf0bece5eb25e1 \ + --hash=sha256:6beca755e6b691096a398df587f544c3f8ff6e9e3a8119d90eae37872a829382 \ + --hash=sha256:6f83664d0e48fd22114dd5ca2cd5202743a7b75b5e6e3fbc6ed61489cee749c8 \ + --hash=sha256:7248c282d7d6aef4ae89363da5fc3df7b9250eb04b252622955296eb5d0ea82f \ + --hash=sha256:727c922e0e8511e22a7f3c81fbcce0aeebc8c7cdb7bd8f13f505394ea9865dcf \ + --hash=sha256:761db18dc32b24d00fbedca1013089eb3470053cefa8492bd9c07b11102d72bf \ + --hash=sha256:76b11b4af067c2b55ffde313ae978d8ab51cfa65046bb70de23448c9dc4e18c4 \ + --hash=sha256:77ef5882c5318ffc3dd94c6abc6a8f1ec2af75d66522319f1a250a07d53d0546 \ + --hash=sha256:77fe6845833339ff6c54be391b56dc5fcff9e7a9acdf7df54647d27e5d3afdd2 \ + --hash=sha256:78c9b5c2cceaeb158d56d03ba9336b3d310c64da6d304296ba39b6c00b8840c1 \ + --hash=sha256:7a553fffd70a518916ce5b595b3067a55c0357732b4f0a96db2dce70a4db717b \ + --hash=sha256:7aa24d49840c016a8256adb1f104f4e064ed9fa7d97523675dd50e08c05b143d \ + --hash=sha256:7c0f9fcfa54570222eb4cbece2fc44b71de3710e0ea6e075bc0ffdc8965ca1ac \ + --hash=sha256:7ec678c52c3ef5e37482a094d109c04cf9e4a0e2923dd1e0fbddb4bf133a976e \ + --hash=sha256:7f38649a7711c301ee231e296e0c8f14376a9d4f9ea7369ecc3cb22187813d9e \ + --hash=sha256:810aea5958d6a2d92593fd27218df528644aeb72cc7f6d401e650752d6c98949 \ + --hash=sha256:81f289389af277345b85aea4b36f4ed2bbdf7504d4466cbec7d28702c4926b01 \ + --hash=sha256:87492842770cd772fce25e9cd279913eaea6c55a91a960b6302aedfa2ec7b152 \ + --hash=sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8 \ + --hash=sha256:90bd49511341dd7c40bb3acaa97cd8b9e5df4821a5714b842117e7069c7cc03e \ + --hash=sha256:91097d66ebb8ca8d23372081a33405a44e2239445614888e38929bd7f5879dae \ + --hash=sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b \ + --hash=sha256:93269f11303fc9a3e3990b57e0533f41949546156d9f0e535571f313032563cc \ + --hash=sha256:96e6966edcfae74ffac2522d2dd1a304450d18807a9791f5ed0a3232192c9437 \ + --hash=sha256:970a3dbce807a3f5c2b8232036052bade01e4bc92ade37d73ea3750418307fa1 \ + --hash=sha256:972a2e19dbaab47d0aa77c1d07f71c930bdc53538f2b33decc905e92a4be5cbd \ + --hash=sha256:972e34c5cbbfe6c90358bfae38edae1a29d3456e920da5a4664c98f221ee4df8 \ + --hash=sha256:9acd83184eed969a08c1afa404a202a3f4287e3580781f2a36e927d4f25363fd \ + --hash=sha256:9f9aba3f8d2e3c22cf233d9bf2c2cfc050989d9da627802845df317d7ab33e84 \ + --hash=sha256:a151eca7da2b04b6bed1f70138b50f5634704b98ee3ee63046316d7661f21b75 \ + --hash=sha256:a26384540c88b7c157899c2a08ff34c180a83ec1de97064d80c68bf8141af2f4 \ + --hash=sha256:a34be674fe74b35a67ce768cbb86b9350caa22c4b04f2eb0d868d9c01f6539d1 \ + --hash=sha256:a3942e2d365f009e5e7839114fa315e1a2bcfea12b52064a28f1a7281862a2a7 \ + --hash=sha256:a3dea7e1ac49e29306500a8627fa3eacabcc9e238d22495eea2d8e61de77065b \ + --hash=sha256:a84e0ae14d1efe782a9f143021c4e01d5ed986a140fe5070505cc5978eaf0592 \ + --hash=sha256:a961713b7bb90671a7d601886f0d603c8276ae95c6c63baaf36957fddba6fa72 \ + --hash=sha256:a998857af4fcd19920bdf5f30f0f1a23f38472a703d33e49168cd4fd32438377 \ + --hash=sha256:aa32b51702a0b6e9fddc0d1dc66cb6ed6af112f1bba0cac2be9039546a862b5c \ + --hash=sha256:b0c32f0660bb21e810ffc1d3d04b6ea682e118b8141c6e12522a3dacadeb70ee \ + --hash=sha256:b0ee9e74480cb7393288ac2ed837e132200bfedd620e765e6db3a3192c9b5b9a \ + --hash=sha256:b0faa0b9af9367748388345d5b12f6add2a9cf896481e72910e88fbfe14ecd54 \ + --hash=sha256:b5b458950312830913f837e179ce9221000ab7a20b4e42d7b48c1fcd79140de1 \ + --hash=sha256:b8b2fc7901b31d5885f26521cf2d74feccdc126a272ba1d879b7545bb25468bc \ + --hash=sha256:b95809496769fb09777136f3e259433ac21c495f3517bf7aab7dfb5a95cfde8b \ + --hash=sha256:b9e56ed83aa2ad78496788afe87d830725a752deb590fa8b7c95e1105da21dc0 \ + --hash=sha256:bd18576a758364b194e9347e0859362e158df99fb0dec3af480578fd9d92ae27 \ + --hash=sha256:be371af72af54b6e508b68a7a87768e05b7e13023f6c82a3e3f5e4d0e829e186 \ + --hash=sha256:bfb2d71edaaa0c6e0e51c52328154c635aec3ee6ea6a29271fbac1a8bc157c12 \ + --hash=sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1 \ + --hash=sha256:c4b68ce579b05c66bb18a16356a4f713bebac2446ee498dd8bb1ecab3da77b89 \ + --hash=sha256:c4c82b91f512942db9bfb4f33ab6b8e17e49cba0e9e98ed34c0cf7c33a48a458 \ + --hash=sha256:c5302747f969d5ec925810176f7697712a3fa2ea0fae0c7a85aa4ad9325247fd \ + --hash=sha256:c741ff72829507be41dc50c731565fa2413287e845e55f0661dedf0a942c1c31 \ + --hash=sha256:c788fdab2eeeab671f5ab375ad010ef743f6c95be67f60f878e8876a51f6c671 \ + --hash=sha256:cba95509120e059fb1a13089428bfba90535b0fa296890650137282a066d7db2 \ + --hash=sha256:cc5eb1be95fb880bd083fb24df064bd5fc8caa85c05cf0ee5f33e2ddf9af625d \ + --hash=sha256:cd678af3f73bcacd0391897a8697e19ba4bd1445b35ba6d776342e64f8c98f29 \ + --hash=sha256:cfa54e7f5bd5ef482e7f5336f7cf3c30ae608d9767e514dd930b7c30a03fa977 \ + --hash=sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0 \ + --hash=sha256:d9ff3dcfaf60de01805e0268a29947205a71742e02797ef07ca46a58ab2a3390 \ + --hash=sha256:dafaeb38712f99e5c31ad7f02c366f1ddd3792921b94066015f8c49f197163b0 \ + --hash=sha256:dca1bf90b3a6a7440904f128464eb95bf46ac9da7ad3c85912483dd83128fd95 \ + --hash=sha256:ddc832886517c5009863209a4a292c4353967cd304ba55822d533e0d59eb6a60 \ + --hash=sha256:de1bf30d819809a964060356032e6670c9923644666afaaf3d1719d05ddb844e \ + --hash=sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39 \ + --hash=sha256:e4a7be59d374c445766fa4ac066a04d19a757365ae00a950053d0ab7388d1039 \ + --hash=sha256:e6c4fa55eab6b5684b33239597876a18a35ce1280b63ab960164032afbb8783f \ + --hash=sha256:ea396d0ab0c9e082e009a16d7bda6849784b411b884d86e287a1b0a36eb278fb \ + --hash=sha256:eaa5178a4c1fe619eff9cbe93b57919d426332e219870301d4ef5fc09a522ae9 \ + --hash=sha256:ecfb50c208c59d9a786262ab06ee25688d1ada618e48a442eb4a12b38835f294 \ + --hash=sha256:ed62c15ce5e8b975b2710a0f7c8bb953cbffb472fccb05055be6afcba9520c19 \ + --hash=sha256:ed8e452f5cb34f59f4861d50d029c6203b5f8f11c13d4186264392a64b4def8d \ + --hash=sha256:eea17e3cdc6f9aecffe15fe9a9eeba8e8c049a5d347de1561eed2240cdf35a79 \ + --hash=sha256:f00ff4b56caf151365c77988eb85a4a71eb26fe63047d4084d5614e06d65e3cf \ + --hash=sha256:f06dd6a30438692d48c0d54de4b659cd55a57c2c2f6591c969c98d0a5523ab07 \ + --hash=sha256:f20bec84117a324b6c4b679d2c748d2813df55e1fe181b3a6bcaa93b6719a772 \ + --hash=sha256:f34405292fca45cd77419005c1ea35ba1c03eaff9042afe76d6fe189fe474669 \ + --hash=sha256:f352f797b1477c46a20e45f4cbc3a0e258e61963c8ae8f79f84a7bfb4195b37e \ + --hash=sha256:f37ccd159dae35d4914651d57142c8077aa67ab7e3ffef690b00bcb7a7dbd8e6 \ + --hash=sha256:f51bca8fcac4266519533698c316f3d23340768a8e57c4796c9f6e0a4f353807 \ + --hash=sha256:f5342b8256476554b4045db8b7bf4dc4044d0405485f220f0cd80764bc0d7faf \ + --hash=sha256:f5fbda9703bce94a976e1a6bbdbbe0af25fb613a593a56091991359928b66c05 \ + --hash=sha256:f760d9a9c441bfc87eee720ddb0946d3e19995b20983ae14038e033f1664efcb \ + --hash=sha256:f9e9f04e5cb640cf7bbf6fdd4e3970e821138df320ae85cad81b4efc2f5cc802 \ + --hash=sha256:faa8520b1c2a3c8216065077e047f84b116405661b1ba070862dac82d464ef39 \ + --hash=sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732 \ + --hash=sha256:fb6a4d400aa531c73a718b337116fad77ba8dc6df34fa615c808bfc55ffb19ca \ + --hash=sha256:ff02e697d947f518e9e99f2b25698045a5ddffa95049c4d95b5a8cf3618d9008 # via httpx -certifi==2026.6.17 \ - --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ - --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db +certifi==2026.10.23 \ + --hash=sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 # via # httpcore # httpx @@ -1021,94 +1064,90 @@ packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via matplotlib -pillow==12.3.0 \ - --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ - --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ - --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ - --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ - --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ - --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ - --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ - --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ - --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ - --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ - --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ - --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ - --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ - --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ - --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ - --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ - --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ - --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ - --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ - --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ - --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ - --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ - --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ - --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ - --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ - --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ - --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ - --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ - --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ - --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ - --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ - --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ - --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ - --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ - --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ - --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ - --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ - --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ - --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ - --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ - --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ - --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ - --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ - --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ - --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ - --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ - --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ - --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ - --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ - --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ - --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ - --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ - --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ - --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ - --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ - --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ - --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ - --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ - --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ - --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ - --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ - --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ - --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ - --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ - --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ - --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ - --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ - --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ - --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ - --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ - --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ - --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ - --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ - --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ - --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ - --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ - --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ - --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ - --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ - --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ - --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ - --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ - --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ - --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ - --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ - --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ - --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 +pillow==13.0.0 \ + --hash=sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27 \ + --hash=sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782 \ + --hash=sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22 \ + --hash=sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15 \ + --hash=sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502 \ + --hash=sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692 \ + --hash=sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f \ + --hash=sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8 \ + --hash=sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13 \ + --hash=sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3 \ + --hash=sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369 \ + --hash=sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88 \ + --hash=sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d \ + --hash=sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb \ + --hash=sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f \ + --hash=sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0 \ + --hash=sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40 \ + --hash=sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599 \ + --hash=sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055 \ + --hash=sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033 \ + --hash=sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061 \ + --hash=sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca \ + --hash=sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f \ + --hash=sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca \ + --hash=sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c \ + --hash=sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4 \ + --hash=sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8 \ + --hash=sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620 \ + --hash=sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071 \ + --hash=sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc \ + --hash=sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a \ + --hash=sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6 \ + --hash=sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e \ + --hash=sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582 \ + --hash=sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f \ + --hash=sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79 \ + --hash=sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5 \ + --hash=sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683 \ + --hash=sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb \ + --hash=sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c \ + --hash=sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa \ + --hash=sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0 \ + --hash=sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521 \ + --hash=sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e \ + --hash=sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c \ + --hash=sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058 \ + --hash=sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2 \ + --hash=sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463 \ + --hash=sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d \ + --hash=sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81 \ + --hash=sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf \ + --hash=sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63 \ + --hash=sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0 \ + --hash=sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42 \ + --hash=sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad \ + --hash=sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81 \ + --hash=sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c \ + --hash=sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4 \ + --hash=sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981 \ + --hash=sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43 \ + --hash=sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039 \ + --hash=sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76 \ + --hash=sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51 \ + --hash=sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2 \ + --hash=sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630 \ + --hash=sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac \ + --hash=sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510 \ + --hash=sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6 \ + --hash=sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3 \ + --hash=sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d \ + --hash=sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61 \ + --hash=sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932 \ + --hash=sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1 \ + --hash=sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d \ + --hash=sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00 \ + --hash=sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe \ + --hash=sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313 \ + --hash=sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806 \ + --hash=sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a \ + --hash=sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0 \ + --hash=sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52 \ + --hash=sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b \ + --hash=sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe # via # -r requirements.in # matplotlib @@ -1286,9 +1325,9 @@ pyparsing==3.3.2 \ --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc # via matplotlib -pypdf==6.16.1 \ - --hash=sha256:63fec31c4092ae50b6729beedcb469055b60d20c834bde1c402df241f371f644 \ - --hash=sha256:c4d1b43ddae921387321cf63936cd16a7743b91d2da92f165c149a195c972ba9 +pypdf==6.21.2 \ + --hash=sha256:0a3e1c5c886a1be03646c16a04827cc166c2a0575c971b26a4bf89686551c2d4 \ + --hash=sha256:1d44d1a3a200f25062adff0085b02040e4646431ac2ccac22e42679e91466084 # via -r requirements.in python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ @@ -1535,9 +1574,9 @@ typing-inspection==0.4.2 \ # via # fastapi # pydantic -urllib3==2.7.0 \ - --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ - --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 # via requests uvicorn==0.51.0 \ --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ diff --git a/web_ui/package-lock.json b/web_ui/package-lock.json index fd19a317..e706e14a 100644 --- a/web_ui/package-lock.json +++ b/web_ui/package-lock.json @@ -149,6 +149,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -899,6 +900,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -947,6 +949,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2583,6 +2586,7 @@ "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", @@ -3385,27 +3389,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true, @@ -3506,6 +3489,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3796,6 +3780,7 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3811,6 +3796,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3822,6 +3808,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3877,6 +3864,7 @@ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", @@ -4263,6 +4251,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4547,9 +4536,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4579,6 +4568,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -4892,6 +4882,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5467,6 +5458,7 @@ "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -5568,9 +5560,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5842,9 +5834,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -9030,6 +9022,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9233,6 +9226,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9245,6 +9239,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9912,9 +9907,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -10394,6 +10389,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10453,9 +10449,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -10690,11 +10686,12 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10770,6 +10767,7 @@ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", @@ -11102,11 +11100,12 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 64e0fea8ba635d70842bc149b2f7019fbd354346 Mon Sep 17 00:00:00 2001 From: dovvnloading <157447210+dovvnloading@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:52:18 -0400 Subject: [PATCH 11/11] Restore the lockfile entries npm audit fix dropped npm audit fix pruned two bundled dependencies of the optional @tailwindcss/oxide-wasm32-wasi package (@emnapi/core and @emnapi/runtime 1.11.1) from package-lock.json. Windows never installs that wasm fallback, so nothing failed locally, but npm ci on Linux rejects the lockfile as out of sync and both the frontend and E2E jobs stopped at their install step. The package itself is unchanged at 4.3.3, so the two entries are restored exactly as main's lockfile has them. npm ci now accepts the lockfile, npm audit still reports no high or critical advisories, and npm run check passes on a clean npm ci install. Co-Authored-By: Claude --- web_ui/package-lock.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/web_ui/package-lock.json b/web_ui/package-lock.json index e706e14a..71f0b914 100644 --- a/web_ui/package-lock.json +++ b/web_ui/package-lock.json @@ -3389,6 +3389,27 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true,