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/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_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/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/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/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/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/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..a0752892 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: @@ -332,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", @@ -425,6 +489,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 +624,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 +632,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 +698,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/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/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/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/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/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/session_load.py b/backend/session_load.py index 93290abf..9591df11 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 @@ -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_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): 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_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/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 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/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") 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_session_load.py b/backend/tests/test_session_load.py index 7a180b43..1f9c108e 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.""" @@ -994,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) 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/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") 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_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 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 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/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/package-lock.json b/web_ui/package-lock.json index fd19a317..71f0b914 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", @@ -3506,6 +3510,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 +3801,7 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3811,6 +3817,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 +3829,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3877,6 +3885,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 +4272,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4547,9 +4557,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 +4589,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -4892,6 +4903,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 +5479,7 @@ "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -5568,9 +5581,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 +5855,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 +9043,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9233,6 +9247,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 +9260,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 +9928,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 +10410,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10453,9 +10470,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 +10707,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 +10788,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 +11121,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" } 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 d829fe62..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 @@ -2303,7 +2314,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 +2329,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 +2366,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 +2379,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 +2389,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 +2401,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 +2428,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 +2455,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..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), }; } @@ -1990,16 +1995,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 +2033,7 @@ function collectGroupMemberDeltaChanges( groupNode: SceneFlowNode, deltaX: number, deltaY: number, + dragging: boolean, visited: Set, ): NodeChange[] { const itemIds = (groupNode.data as { itemIds?: string[] }).itemIds ?? []; @@ -2030,11 +2046,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/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); } 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 }; });