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 = "
"
+ # 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"] == "
"
+ 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: "
");
+ // 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: "
");
+ });
+
+ 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: "
"));
+ // 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: "
"));
+
+ // 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: "
" } });
+ fireEvent.blur(textarea);
+ expect(onSourceCommit).toHaveBeenCalledTimes(2);
+
+ // Both commits are in flight; the first one's echo lands now.
+ rerenderWith({ htmlContent: "
"));
+ 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: "
"));
+ });
+
+ 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: "
");
+ });
+});
+
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: "
");
});
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: "
"] }]);
+ // 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 };
});