Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions plugins/system_prompt/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from __future__ import annotations

from backend.canvas import SceneDocument
from backend.domain.node_access import is_node_of
from backend.domain.node_states import NoteState
from backend.plugin_sdk import HostContext, PluginRunContext


Expand All @@ -46,17 +48,26 @@ def _execute(
# the note -> root edge _resolve_branch_system_prompt looks for.
return _create_pending(document, run_ctx)
root = document.get_branch_root(parent_node_id)
existing = next(
(
document.nodes[edge.source]
for edge in document.edges.values()
if edge.target == root.id
and edge.source in document.nodes
and document.nodes[edge.source].kind == "note"
and document.nodes[edge.source].state.is_system_prompt
),
None,
)
if root is None:
# get_branch_root returns None only for an id it does not know, and
# the guard above already established that this one is in
# document.nodes - so this cannot fire today. It is here because the
# alternative is an AttributeError on the next line if that ever
# stops being true, and because the plugin already has a sensible
# answer for "no usable parent": make the prompt unattached.
return _create_pending(document, run_ctx)
# A loop rather than the generator this used to be: the kind check and
# the field read have to happen on the SAME bound node for either a
# reader or a checker to see that the second follows from the first.
# Identical result - the first match in edge order, or None.
existing = None
for edge in document.edges.values():
if edge.target != root.id or edge.source not in document.nodes:
continue
candidate = document.nodes[edge.source]
if is_node_of(candidate, "note", NoteState) and candidate.state.is_system_prompt:
existing = candidate
break
if existing is not None:
return existing.id

Expand Down Expand Up @@ -85,7 +96,7 @@ def _create_pending(
(
node
for node in document.nodes.values()
if node.kind == "note"
if is_node_of(node, "note", NoteState)
and node.state.is_system_prompt
and not any(edge.source == node.id for edge in document.edges.values())
),
Expand Down
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,21 @@ files = [
"graphlink_token_estimator.py",
"graphlink_version.py",
"graphlink_wire_schema.py",
# 2026-09-05: the three directories a coverage check found sitting outside
# the gate - 14 files nobody was checking. plugins/ held six real errors
# (an unguarded Optional and two unnarrowed node-state reads in the System
# Prompt plugin); mutation_tests/ and tests_e2e/ were already clean.
"mutation_tests",
"plugins",
"tests_e2e",
]
# plugins/ is eight sibling directories each containing a `plugin.py`, with no
# __init__.py anywhere - the layout the plugin loader discovers by path. Without
# these two, mypy maps all eight onto one module name and refuses to check any
# of them ("Duplicate module named 'plugin'"). With them, the module name comes
# from the path relative to the repo root, so they stay distinct.
namespace_packages = true
explicit_package_bases = true
ignore_missing_imports = true
# Check the files in `files` fully; use everything they import for type
# information without reporting errors inside it. Without this, listing
Expand Down
Loading