diff --git a/backend/api/intents_builder.py b/backend/api/intents_builder.py
index 472de4fa..ea385e01 100644
--- a/backend/api/intents_builder.py
+++ b/backend/api/intents_builder.py
@@ -152,6 +152,31 @@ async def save_recipe(node_id, name):
await bus.publish("notification")
return clean_name
+ async def delete_recipe(name):
+ """stage 8.7: rounds out the recipe lifecycle - saveRecipe already
+ creates one from a finished build, but nothing could ever remove
+ one. Mirrors save_recipe's own name-guard against built-ins and its
+ own settings.get_recipes()/set_recipes() replace-the-whole-list
+ posture."""
+ from backend.builder import BUILT_IN_RECIPES
+
+ clean_name = str(name or "").strip()
+ if any(r["name"] == clean_name for r in BUILT_IN_RECIPES):
+ notifications.show(f'"{clean_name}" is a built-in recipe - it cannot be deleted.', "warning")
+ await bus.publish("notification")
+ return False
+ settings = agent_dispatcher._settings_manager
+ existing = settings.get_recipes()
+ remaining = [r for r in existing if r["name"] != clean_name]
+ if len(remaining) == len(existing):
+ notifications.show(f'No saved recipe named "{clean_name}".', "info")
+ await bus.publish("notification")
+ return False
+ settings.set_recipes(remaining)
+ notifications.show(f'Deleted recipe "{clean_name}".', "info")
+ await bus.publish("notification")
+ return True
+
async def start_execution(node_id):
node = document.nodes.get(node_id)
if node is None or not isinstance(node.state, PlanState):
@@ -202,4 +227,5 @@ async def set_plan_steps(node_id, steps):
bus.register_intent("builder", "denyTool", deny_tool)
bus.register_intent("builder", "listRecipes", list_recipes)
bus.register_intent("builder", "saveRecipe", save_recipe)
+ bus.register_intent("builder", "deleteRecipe", delete_recipe)
bus.register_intent("scene", "setPlanSteps", set_plan_steps)
diff --git a/backend/builder.py b/backend/builder.py
index 7276aff1..248b2802 100644
--- a/backend/builder.py
+++ b/backend/builder.py
@@ -74,6 +74,27 @@
_APPROVAL_SUMMARY_CAP = 400
+# stage 8.7: the activity log's own caps. A build is bounded at 50 steps x
+# _STEP_TURN_CAP turns, so nothing else bounds how many rows a pathological
+# replan loop could otherwise append to a plan node's wire dict (a whole-
+# node diff per graph.py's take_dirty_patch_ops) - the ring buffer is the
+# backstop. The summary cap is tighter than approval's own 400: approval
+# summaries are shown one at a time, activity rows are shown many at once.
+_ACTIVITY_CAP = 100
+_ACTIVITY_SUMMARY_CAP = 200
+# review-fix: a tool CALL's name has no upstream length validation - it is
+# whatever a provider's tool-call parsing extracted from the model's own
+# output verbatim (e.g. providers/ollama_provider.py's _extract_tool_calls),
+# so a malformed/hallucinating turn (most reachable with a local model) can
+# emit an arbitrarily large one. Every OTHER field this row stores is
+# capped; leaving `tool` uncapped would let exactly that turn defeat the
+# same wire/session-size bound the ring buffer and summary cap exist for.
+_ACTIVITY_TOOL_NAME_CAP = 80
+
+# stage 8.7: which terminal/pause statuses notify, and with what severity -
+# keyed by the exact status string _land() writes to builder_status.
+_LAND_NOTIFICATION_KINDS = {"done": "success", "failed": "error", "paused": "warning"}
+
PLAN_SCHEMA = {
"type": "object",
"properties": {
@@ -368,11 +389,42 @@ def plan_steps_for_goal(goal: str, *, runtime=None, settings_manager=None) -> li
# -- the executor loop -------------------------------------------------------
+def _truncate(text: str, cap: int) -> str:
+ return text if len(text) <= cap else text[:cap] + "…"
+
+
def _approval_summary(call: ToolCall) -> str:
args = json.dumps(call.arguments, sort_keys=True, ensure_ascii=False)
- if len(args) > _APPROVAL_SUMMARY_CAP:
- args = args[:_APPROVAL_SUMMARY_CAP] + "…"
- return f"{call.name} {args}"
+ return f"{call.name} {_truncate(args, _APPROVAL_SUMMARY_CAP)}"
+
+
+def _activity_summary(call: ToolCall, result: ToolResult) -> str:
+ """What a build's activity row shows for one invoked call. An error
+ (a real failure OR an approval denial - invoke() returns denial as
+ ToolResult(is_error=True, content="...was denied approval.") and the
+ two are not otherwise distinguishable here) shows the tool's own
+ result text, since that already says exactly what happened. A success
+ shows the call's arguments instead - the result of, say,
+ graph.read_subgraph is the interesting part to the MODEL, not to a
+ human scanning what the build did."""
+ text = result.content if result.is_error else json.dumps(call.arguments, sort_keys=True, ensure_ascii=False)
+ return _truncate(text, _ACTIVITY_SUMMARY_CAP)
+
+
+def _log_activity(node, *, tool: str, summary: str, outcome: str, step_id: str, elapsed_ms: int) -> None:
+ """Appends one row to the plan node's activity log - see PlanState's
+ own docstring for why this is deliberately untouched by undo. Trims
+ from the front (oldest first) once the ring buffer's cap is exceeded."""
+ activity = node.state.builder_activity
+ activity.append({
+ "tool": _truncate(tool, _ACTIVITY_TOOL_NAME_CAP),
+ "summary": summary,
+ "outcome": outcome,
+ "stepId": step_id,
+ "elapsedMs": elapsed_ms,
+ })
+ if len(activity) > _ACTIVITY_CAP:
+ del activity[: len(activity) - _ACTIVITY_CAP]
def _rough_token_count(text: str) -> int:
@@ -532,6 +584,16 @@ async def _land(status: str, detail: str) -> None:
if node.pending_request_id == request_id:
node.pending_request_id = None
await bus.publish("scene")
+ # stage 8.7: a build that lands while the user is elsewhere on the
+ # canvas (or the app is unfocused) must not announce nothing.
+ # "stopped" is excluded - the user just clicked Stop and is
+ # necessarily present, so a notification for an action they took
+ # themselves is noise. "interrupted" never reaches this function
+ # (it is a session_load-time normalization, not a run outcome).
+ kind = _LAND_NOTIFICATION_KINDS.get(status)
+ if kind is not None and notifications is not None and detail:
+ notifications.show(detail, kind)
+ await bus.publish("notification")
node.state.builder_status = "running"
node.state.builder_status_detail = ""
@@ -657,7 +719,16 @@ async def _land(status: str, detail: str) -> None:
else:
await _land("paused", breach + " Raise the budget and resume to continue.")
return
+ call_started = time.monotonic()
result = await registry.invoke(call, ctx)
+ # stage 8.7: every invoked call, including the four
+ # in-band control tools - hiding those would make the
+ # log lie about how many turns the build actually took.
+ _log_activity(
+ node, tool=call.name, summary=_activity_summary(call, result),
+ outcome="error" if result.is_error else "ok",
+ step_id=step["id"], elapsed_ms=max(0, round((time.monotonic() - call_started) * 1000)),
+ )
messages.append({
"role": "tool", "tool_call_id": call.id,
"name": call.name, "content": result.content,
@@ -715,10 +786,10 @@ async def _land(status: str, detail: str) -> None:
# the timeout path above so resume doesn't skip the in-flight step.
if step is not None and step.get("status") == "running":
step["status"] = "pending"
+ # _land("failed", ...) now sends this failure's own notification
+ # (see _LAND_NOTIFICATION_KINDS) - a second one here would be a
+ # duplicate banner for the same event.
await _land("failed", f"Build failed: {exc} — resume to retry.")
- if notifications is not None:
- notifications.show(f"Build failed: {exc}", "error")
- await bus.publish("notification")
def _apply_replan(document, node, controls: BuilderControls, run_id: str) -> None:
diff --git a/backend/domain/commands.py b/backend/domain/commands.py
index ebb753dd..b93bb8c1 100644
--- a/backend/domain/commands.py
+++ b/backend/domain/commands.py
@@ -236,6 +236,31 @@ def _restore(live: dict, snapshot: dict) -> None:
restored.state.builder_awaiting_tool_approval = False
restored.state.builder_approval_tool_name = ""
restored.state.builder_approval_summary = ""
+ # review-fix (stage 8.7): builder_activity is documented as run
+ # TELEMETRY, not reversible document content (PlanState's own
+ # docstring: "deliberately left untouched by an undo, so the
+ # record of what happened survives reverting what happened") -
+ # unlike plan_steps, which genuinely IS reversible content
+ # (scene/setPlanSteps is A-classified for exactly that reason).
+ # A command recorded MID-RUN (builderReplan fires on every
+ # builder.replan call, not just once at the run's start)
+ # snapshots the node with whatever activity existed at THAT
+ # instant; restoring that snapshot verbatim would silently erase
+ # every row logged afterward the moment the command is inverted
+ # - the same phantom-state class of hazard pending_request_id's
+ # unconditional reset above already guards against, just for a
+ # different field. Carries the CURRENT node's activity log
+ # forward instead of trusting the snapshot's stale copy - `live`
+ # still holds the pre-restore node at this point, one line
+ # before it is replaced. A node that does not currently exist
+ # (this restore is recreating a deleted one) has no "current" to
+ # preserve, so the snapshot's own activity is used as-is - the
+ # only case where trusting the snapshot is correct.
+ current = live.get(key)
+ if isinstance(getattr(restored, "state", None), PlanState) and isinstance(
+ getattr(current, "state", None), PlanState,
+ ):
+ restored.state.builder_activity = current.state.builder_activity
live[key] = restored
diff --git a/backend/domain/graph.py b/backend/domain/graph.py
index 8aaadc77..aac0b6bd 100644
--- a/backend/domain/graph.py
+++ b/backend/domain/graph.py
@@ -2346,6 +2346,19 @@ def _node_wire(self, n: SceneNode) -> dict[str, Any]:
]
if isinstance(n.state, PlanState) else []
),
+ # ADR-008 stage 8.7: the run's own activity log - see PlanState's
+ # docstring for why this is untouched by undo.
+ "builderActivity": (
+ [
+ {
+ "tool": a["tool"], "summary": a["summary"],
+ "outcome": a["outcome"], "stepId": a["stepId"],
+ "elapsedMs": a["elapsedMs"],
+ }
+ for a in n.state.builder_activity
+ ]
+ if isinstance(n.state, PlanState) else []
+ ),
"builderStatus": n.state.builder_status if isinstance(n.state, PlanState) else "",
"builderMode": n.state.builder_mode if isinstance(n.state, PlanState) else "",
"builderRunId": n.state.builder_run_id if isinstance(n.state, PlanState) else "",
diff --git a/backend/domain/node_states.py b/backend/domain/node_states.py
index 0d62b08a..ac02530e 100644
--- a/backend/domain/node_states.py
+++ b/backend/domain/node_states.py
@@ -774,6 +774,20 @@ class PlanState(NodeState):
own precedent directly above: the wire layer owns the typed row
(PlanStepRow in contracts/), the domain keeps the flexible shape.
+ `activity` (stage 8.7) items are plain dicts {"tool": str, "summary":
+ str, "outcome": "ok"|"error", "stepId": str, "elapsedMs": int} - one
+ row per tool the loop invoked, in call order, written by builder.py's
+ _log_activity at its single registry.invoke() choke point. This is the
+ build's own visible record of WHAT IT DID - distinct from undo (undo_run
+ reverts document MUTATIONS; a build's activity log is run telemetry and
+ is deliberately left untouched by an undo, so the record of what
+ happened survives reverting what happened) and distinct from a chat
+ node's tool_invocations (that field holds one TURN's calls for a
+ completed reply; this holds a whole BUILD's calls across every turn and
+ every step). A capped ring buffer (see builder.py's _ACTIVITY_CAP) -
+ a build is bounded at 50 steps x 8 turns, so nothing else bounds this
+ list's growth.
+
`builder_status` is the loop's own state machine:
draft -> planning -> awaiting_start -> running <-> awaiting_approval
running -> paused (budget breach; resumable)
@@ -797,6 +811,7 @@ class PlanState(NodeState):
plan_goal: str = ""
plan_steps: list[dict[str, Any]] = field(default_factory=list)
+ builder_activity: list[dict[str, Any]] = field(default_factory=list)
builder_status: str = "draft"
builder_mode: str = "copilot" # "copilot" | "autopilot"
builder_run_id: str = ""
diff --git a/backend/session_load.py b/backend/session_load.py
index 82e2bbdf..d815fdb7 100644
--- a/backend/session_load.py
+++ b/backend/session_load.py
@@ -713,6 +713,25 @@ def _restore_plan_payload(payload: dict[str, Any]) -> SceneNode:
"detail": str(raw.get("detail", "")),
})
mode = str(payload.get("builder_mode", "copilot") or "copilot")
+ activity = []
+ for raw in payload.get("activity") or []:
+ if isinstance(raw, dict) and raw.get("tool"):
+ # review-fix: elapsedMs is untrusted input from a saved file
+ # (hand-edited, or written by an older/different format) - a
+ # non-numeric value must degrade to 0, the same tolerance every
+ # other field on this row already gets via str(), not crash the
+ # whole session load the way a bare int() would.
+ try:
+ elapsed_ms = int(raw.get("elapsedMs", 0) or 0)
+ except (TypeError, ValueError):
+ elapsed_ms = 0
+ activity.append({
+ "tool": str(raw.get("tool", "")),
+ "summary": str(raw.get("summary", "")),
+ "outcome": str(raw.get("outcome", "ok")),
+ "stepId": str(raw.get("stepId", "")),
+ "elapsedMs": elapsed_ms,
+ })
return SceneNode(
id="", x=x, y=y,
title=f"Build: {goal[:40]}" if goal else "Build",
@@ -721,6 +740,7 @@ def _restore_plan_payload(payload: dict[str, Any]) -> SceneNode:
state=PlanState(
plan_goal=goal,
plan_steps=steps,
+ builder_activity=activity,
builder_status=status,
builder_mode=mode if mode in ("copilot", "autopilot") else "copilot",
builder_run_id=str(payload.get("builder_run_id", "")),
diff --git a/backend/session_save.py b/backend/session_save.py
index 79e23bcc..a0d42814 100644
--- a/backend/session_save.py
+++ b/backend/session_save.py
@@ -403,6 +403,12 @@ def _serialize_plan_node(node: SceneNode) -> dict[str, Any]:
"node_type": "plan",
"goal": node.state.plan_goal,
"steps": [dict(s) for s in node.state.plan_steps],
+ # stage 8.7: the run's activity log - persisted like every other
+ # PlanState field so it survives restart alongside the resume
+ # point, and (unlike the LIVE-run fields below) it describes past
+ # calls rather than an in-flight RunHandle, so nothing here needs
+ # load-time normalization.
+ "activity": [dict(a) for a in node.state.builder_activity],
"builder_status": node.state.builder_status,
"builder_mode": node.state.builder_mode,
"builder_run_id": node.state.builder_run_id,
diff --git a/backend/tests/test_builder.py b/backend/tests/test_builder.py
index e11c64ca..111252c0 100644
--- a/backend/tests/test_builder.py
+++ b/backend/tests/test_builder.py
@@ -18,13 +18,16 @@
import api_provider
from backend import builder as builder_module
+from backend import tools_graph as tools_graph_module
from backend.builder import run_build
from backend.domain.graph import SceneDocument
+from backend.domain.model import MESSAGE_VERTICAL_SPACING
+from backend.notifications import NotificationState
from backend.providers.base import ToolCall
from backend.run_lifecycle import RunRegistry
from backend.tests.test_canvas import make_bus_with_dispatcher
from backend.tools import ToolRegistry
-from backend.tools_graph import register_graph_tools, register_run_node_tool
+from backend.tools_graph import _place_child, register_graph_tools, register_run_node_tool
from backend.builder import register_builder_control_tools
@@ -99,7 +102,7 @@ def seed_plan(document, steps, *, mode="copilot", **budgets):
return node
-async def drive_build(document, dispatcher, registry, bus, node, *, approve=True, deny_first=False):
+async def drive_build(document, dispatcher, registry, bus, node, *, approve=True, deny_first=False, notifications=None):
"""Runs run_build while a driver coroutine plays the approving human.
Returns (approvals_seen, denials_issued)."""
cancel_event = threading.Event()
@@ -111,7 +114,7 @@ async def run():
try:
await run_build(
document=document, dispatcher=dispatcher, registry=registry,
- bus=bus, notifications=None, plan_node_id=node.id,
+ bus=bus, notifications=notifications, plan_node_id=node.id,
request_id=handle.request_id, handle=handle, cancel_event=cancel_event,
)
finally:
@@ -829,3 +832,402 @@ def boom_turn(task, messages, tools=(), **kwargs):
"a transient provider fault must not permanently kill a build "
"whose goal/checklist/spent budgets are still on the canvas"
)
+
+
+class TestActivityLog:
+ """stage 8.7: the build's own visible record of what it did."""
+
+ def test_activity_rows_are_written_for_ok_and_error_calls_including_control_tools(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [
+ call("c1", "graph.create_node", kind="note", content="x"),
+ # pycoder requires parent_id - this call errors.
+ call("c2", "graph.create_node", kind="pycoder"),
+ ]},
+ {"tool_calls": [call("c3", "builder.complete_step", summary="done")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+
+ rows = node.state.builder_activity
+ tools = [r["tool"] for r in rows]
+ assert tools == ["graph.create_node", "graph.create_node", "builder.complete_step"], (
+ "every invoked call is logged in order, including the control tool"
+ )
+ assert rows[0]["outcome"] == "ok"
+ assert rows[1]["outcome"] == "error"
+ assert rows[2]["outcome"] == "ok"
+ assert all(r["stepId"] == "s1" for r in rows)
+ assert all(isinstance(r["elapsedMs"], int) and r["elapsedMs"] >= 0 for r in rows)
+
+ def test_a_denied_call_logs_as_error_with_the_denial_text(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [call("c1", "graph.create_node", kind="note", content="first try")]},
+ {"tool_calls": [call("c2", "builder.complete_step", summary="ok, done without it")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node, deny_first=True))
+
+ rows = node.state.builder_activity
+ assert rows[0]["tool"] == "graph.create_node"
+ assert rows[0]["outcome"] == "error"
+ assert "denied" in rows[0]["summary"]
+
+ def test_activity_is_a_capped_ring_buffer(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ # autopilot: 120 graph.mutate calls with no approval round-trip each.
+ node = seed_plan(document, ["one step"], mode="autopilot", max_tokens=10_000_000, max_wall_seconds=10_000)
+ calls = [call(f"c{i}", "graph.create_node", kind="note", content=str(i)) for i in range(120)]
+ calls.append(call("cfinal", "builder.complete_step", summary="done"))
+ scripted_turns(monkeypatch, [{"tool_calls": calls}], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+
+ assert node.state.builder_status == "done"
+ assert len(node.state.builder_activity) == builder_module._ACTIVITY_CAP
+ # Oldest dropped first: the newest row logged is always last.
+ assert node.state.builder_activity[-1]["tool"] == "builder.complete_step"
+
+ def test_undo_run_leaves_the_activity_log_intact(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [call("c1", "graph.create_node", kind="note", content="x")]},
+ {"tool_calls": [call("c2", "builder.complete_step", summary="done")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+ logged = len(node.state.builder_activity)
+ assert logged > 0
+
+ document.undo_run(node.state.builder_run_id)
+
+ assert len(node.state.builder_activity) == logged, (
+ "activity is run telemetry, not document content - undo_run must not touch it"
+ )
+
+ def test_activity_round_trips_through_session_save_and_load(self):
+ from backend.session_load import _restore_plan_payload
+ from backend.session_save import _serialize_plan_node
+
+ document = SceneDocument()
+ node = document.add_plan_node(0, 0, "the goal")
+ node.state.builder_activity = [
+ {"tool": "graph.create_node", "summary": "{}", "outcome": "ok", "stepId": "s1", "elapsedMs": 42},
+ ]
+
+ restored = _restore_plan_payload(_serialize_plan_node(node))
+
+ assert restored.state.builder_activity == node.state.builder_activity
+
+ def test_session_load_drops_malformed_activity_entries_instead_of_crashing(self):
+ from backend.session_load import _restore_plan_payload
+
+ payload = {
+ "goal": "g",
+ "activity": [
+ {"tool": "graph.create_node", "summary": "ok row", "outcome": "ok", "stepId": "s1", "elapsedMs": 5},
+ "not a dict",
+ {"summary": "missing the tool key entirely"},
+ {"tool": "run_node", "summary": "s", "outcome": "ok", "stepId": "s1", "elapsedMs": "not a number"},
+ ],
+ }
+
+ restored = _restore_plan_payload(payload)
+
+ assert len(restored.state.builder_activity) == 2, (
+ "the non-dict entry and the entry missing 'tool' are dropped, "
+ "matching plan_steps' own malformed-entry tolerance"
+ )
+ assert restored.state.builder_activity[0]["tool"] == "graph.create_node"
+ assert restored.state.builder_activity[1]["elapsedMs"] == 0, (
+ "a non-numeric elapsedMs coerces to 0 rather than crashing session load"
+ )
+
+ def test_review_fix_undo_run_does_not_revert_activity_logged_after_a_mid_run_replan(self, monkeypatch):
+ """A command recorded mid-run (builderReplan fires on every
+ builder.replan call, not just once) snapshots the plan node with
+ whatever activity existed at that instant. Reverting that command -
+ via undo_run, which walks the whole run's commands - must not
+ silently erase rows logged afterward; see commands.py's own
+ review-fix in _restore for the mechanism."""
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["s1", "s2", "s3"])
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [call("c1", "builder.complete_step", summary="s1 done")]},
+ # The replan's own command snapshots the node right after this
+ # call's own activity row is appended.
+ {"tool_calls": [call("c2", "builder.replan", steps=["new s3"], reason="r")]},
+ # Everything below is logged AFTER that snapshot.
+ {"tool_calls": [call("c3", "graph.create_node", kind="note", content="after replan")]},
+ {"tool_calls": [call("c4", "builder.complete_step", summary="s2 done")]},
+ {"tool_calls": [call("c5", "builder.complete_step", summary="new s3 done")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+
+ assert node.state.builder_status == "done"
+ activity_before_undo = list(node.state.builder_activity)
+ assert len(activity_before_undo) == 5, "one row per invoked call"
+
+ document.undo_run(node.state.builder_run_id)
+
+ assert node.state.builder_activity == activity_before_undo, (
+ "undo_run inverts the builderReplan command among the run's "
+ "others - its mid-run snapshot must not erase rows logged after it"
+ )
+
+ def test_activity_is_a_capped_ring_buffer_at_the_exact_boundary(self, monkeypatch):
+ """review-fix: the existing ring-buffer test only checks aggregate
+ length and the newest row after a large overshoot; this pins the
+ exact trim at the boundary - one run under the cap, one exactly at
+ it, one one-over - and which specific rows survive."""
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"], mode="autopilot", max_tokens=10_000_000, max_wall_seconds=10_000)
+ # _ACTIVITY_CAP + 1 create_node calls, then complete_step - the cap
+ # is breached by exactly one row.
+ calls = [
+ call(f"c{i}", "graph.create_node", kind="note", content=str(i))
+ for i in range(builder_module._ACTIVITY_CAP + 1)
+ ]
+ calls.append(call("cfinal", "builder.complete_step", summary="done"))
+ scripted_turns(monkeypatch, [{"tool_calls": calls}], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+
+ activity = node.state.builder_activity
+ assert len(activity) == builder_module._ACTIVITY_CAP
+ # The trim runs after EVERY append that exceeds the cap, not once
+ # at the end: 101 create_node calls (content "0".."100") first push
+ # the log to 101 rows, dropping content "0"; the trailing
+ # complete_step then pushes it to 101 again, dropping content "1"
+ # too - "2" is the oldest of the two content rows to survive both
+ # trims.
+ assert activity[0]["summary"] == '{"content": "2", "kind": "note"}'
+ assert activity[-1]["tool"] == "builder.complete_step"
+
+ def test_activity_tool_name_and_summary_are_both_truncated_when_over_cap(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ long_tool_name = "graph." + "x" * 200
+ long_content = "y" * 500
+
+ from backend.providers.base import ToolSpec
+ from backend.tools import GRAPH_READ, ToolResult
+
+ async def oversized_handler(call, ctx):
+ return ToolResult(content="ok")
+
+ registry.register(
+ ToolSpec(name=long_tool_name, description="d", input_schema={"type": "object"}),
+ oversized_handler, scopes={GRAPH_READ}, approval="auto",
+ )
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [call("c1", long_tool_name, content=long_content)]},
+ {"tool_calls": [call("c2", "builder.complete_step", summary="done")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+
+ row = node.state.builder_activity[0]
+ # Pinned explicitly: an error result would ALSO produce a long,
+ # truncated summary (the error text embeds the oversized tool
+ # name), so without this the assertions below could pass for the
+ # wrong reason - the success path never actually being exercised.
+ assert row["outcome"] == "ok"
+ # _truncate appends a trailing ellipsis character on top of the cap,
+ # so the truncated length is cap + 1, not cap itself.
+ assert len(row["tool"]) == builder_module._ACTIVITY_TOOL_NAME_CAP + 1
+ assert row["tool"].endswith("…")
+ assert len(row["summary"]) == builder_module._ACTIVITY_SUMMARY_CAP + 1
+ assert row["summary"].endswith("…")
+
+
+class TestLandNotifications:
+ """stage 8.7: a build that lands while the user is elsewhere must not
+ announce nothing."""
+
+ def test_done_notifies_success(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ notifications = NotificationState()
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [call("c1", "builder.finish_build", summary="all done")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node, notifications=notifications))
+
+ assert notifications.msg_type == "success"
+ assert notifications.message == "all done"
+
+ def test_paused_notifies_warning(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"], max_tokens=1)
+ notifications = NotificationState()
+ scripted_turns(monkeypatch, [
+ {
+ "tool_calls": [call("c1", "graph.create_node", kind="note", content="x")],
+ "usage": {"prompt_tokens": 5, "completion_tokens": 5},
+ },
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node, notifications=notifications))
+
+ assert notifications.msg_type == "warning"
+ assert "budget" in notifications.message.lower()
+
+ def test_failed_notifies_error(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["a step"])
+ notifications = NotificationState()
+
+ def boom_turn(task, messages, tools=(), **kwargs):
+ raise RuntimeError("rate limited")
+
+ monkeypatch.setattr(api_provider, "chat_turn_with_tools", boom_turn)
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node, notifications=notifications))
+
+ assert notifications.msg_type == "error"
+ assert "rate limited" in notifications.message
+
+ def test_stopped_does_not_notify(self):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ notifications = NotificationState()
+ cancel_event = threading.Event()
+ cancel_event.set() # pre-cancelled: the loop's own top-of-loop check fires first
+ handle = dispatcher._runs.claim("builder", node_id=node.id, cancel_event=cancel_event)
+
+ async def run():
+ try:
+ await run_build(
+ document=document, dispatcher=dispatcher, registry=registry,
+ bus=bus, notifications=notifications, plan_node_id=node.id,
+ request_id=handle.request_id, handle=handle, cancel_event=cancel_event,
+ )
+ finally:
+ dispatcher._runs.release(handle.request_id)
+
+ asyncio.run(run())
+
+ assert node.state.builder_status == "stopped"
+ assert notifications.message == "", (
+ "Stop is user-initiated - a notification for an action the user just took is noise"
+ )
+
+
+class TestAnchoredPlacement:
+ """stage 8.7: a build's parentless creates land near its plan node
+ instead of scattering at the canvas origin."""
+
+ def test_a_parentless_create_anchors_near_the_plan_node(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ node.x, node.y = 500.0, 300.0
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [call("c1", "graph.create_node", kind="note", content="x")]},
+ {"tool_calls": [call("c2", "builder.complete_step", summary="done")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+
+ created = next(n for n in document.nodes.values() if n.kind == "note")
+ assert created.x == node.x
+ assert created.y == node.y + MESSAGE_VERTICAL_SPACING
+
+ def test_multiple_parentless_creates_fan_out_along_the_anchor_row(self, monkeypatch):
+ document, dispatcher, registry, bus = make_harness()
+ node = seed_plan(document, ["one step"])
+ node.x, node.y = 100.0, 100.0
+ scripted_turns(monkeypatch, [
+ {"tool_calls": [
+ call("c1", "graph.create_node", kind="note", content="a"),
+ call("c2", "graph.create_node", kind="note", content="b"),
+ ]},
+ {"tool_calls": [call("c3", "builder.complete_step", summary="done")]},
+ ], [])
+
+ asyncio.run(drive_build(document, dispatcher, registry, bus, node))
+
+ notes = sorted((n for n in document.nodes.values() if n.kind == "note"), key=lambda n: n.x)
+ assert len(notes) == 2
+ assert notes[0].x == node.x
+ assert notes[1].x == node.x + tools_graph_module._SIBLING_HORIZONTAL_SPACING
+ assert notes[0].y == notes[1].y == node.y + MESSAGE_VERTICAL_SPACING
+
+ def test_no_anchor_falls_back_to_the_origin_drop(self):
+ document = SceneDocument()
+ assert _place_child(document, None, None) == (80.0, 80.0)
+
+ def test_review_fix_an_explicit_parent_id_equal_to_the_anchor_does_not_overlap_an_anchor_placed_sibling(self):
+ """The executor prompt tells the model the plan node's own id
+ (builder.py: "The plan node's id is {plan_node_id}."), and nothing
+ stops it passing that back as parent_id for a chat/code node -
+ which used to count siblings by EDGE (invisible to a note the
+ anchor branch placed with no edge at all), landing the two nodes on
+ top of each other. Both branches must now agree on the same row."""
+ document = SceneDocument()
+ plan = document.add_plan_node(200.0, 200.0, "goal")
+
+ x1, y1 = _place_child(document, None, plan.id) # parentless -> anchor branch
+ document.add_note(x1, y1)
+ x2, y2 = _place_child(document, plan.id, plan.id) # parent_id IS the anchor
+
+ assert (x2, y2) != (x1, y1), "must not collide with the anchor-placed sibling"
+ assert (x2, y2) == (x1 + tools_graph_module._SIBLING_HORIZONTAL_SPACING, y1)
+
+ def test_review_fix_a_real_parent_distinct_from_the_anchor_keeps_its_own_edge_based_counting(self):
+ """The unification fix must be scoped to parent_id == anchor_id
+ only - a normal parent (anything else) keeps exactly its prior,
+ unrelated-to-the-builder edge-based placement."""
+ document = SceneDocument()
+ plan = document.add_plan_node(0.0, 0.0, "goal")
+ parent = document.add_chat_node(500.0, 500.0, "p", True)
+
+ x, y = _place_child(document, parent.id, plan.id)
+
+ assert (x, y) == (parent.x, parent.y + MESSAGE_VERTICAL_SPACING)
+
+
+class TestDeleteRecipe:
+ def test_deletes_a_saved_recipe(self):
+ async def run():
+ bus, document, recorder, dispatcher = make_bus_with_dispatcher()
+ source = document.add_plan_node(0, 0, "goal")
+ source.state.plan_steps = [{"id": "s1", "title": "step", "status": "done", "detail": ""}]
+ await bus.dispatch_intent("builder", "saveRecipe", [source.id, "My recipe"])
+
+ result = await bus.dispatch_intent("builder", "deleteRecipe", ["My recipe"])
+ assert result is True
+
+ listing = await bus.dispatch_intent("builder", "listRecipes", [])
+ names = [r["name"] for r in listing["recipes"]]
+ assert "My recipe" not in names
+ assert "Research and summarize" in names, "built-ins are untouched"
+
+ asyncio.run(run())
+
+ def test_refuses_to_delete_a_built_in(self):
+ async def run():
+ bus, document, recorder, dispatcher = make_bus_with_dispatcher()
+ result = await bus.dispatch_intent("builder", "deleteRecipe", ["Research and summarize"])
+ assert result is False
+
+ listing = await bus.dispatch_intent("builder", "listRecipes", [])
+ names = [r["name"] for r in listing["recipes"]]
+ assert "Research and summarize" in names
+
+ asyncio.run(run())
+
+ def test_deleting_an_unknown_name_is_a_no_op_not_an_error(self):
+ async def run():
+ bus, document, recorder, dispatcher = make_bus_with_dispatcher()
+ result = await bus.dispatch_intent("builder", "deleteRecipe", ["Nonexistent"])
+ assert result is False
+
+ asyncio.run(run())
diff --git a/backend/tools_graph.py b/backend/tools_graph.py
index 1c861348..7b468d8b 100644
--- a/backend/tools_graph.py
+++ b/backend/tools_graph.py
@@ -28,7 +28,10 @@
Placement: the model never picks coordinates. New nodes land relative to
their parent using the same MESSAGE_VERTICAL_SPACING convention
send_message's own reply placement uses (backend/domain/model.py), with a
-horizontal fan-out for siblings so parallel children don't stack.
+horizontal fan-out for siblings so parallel children don't stack. A
+parentless create (stage 8.7) instead anchors near the run's plan node, if
+one exists, so a build's output lands where the user just looked rather
+than at the canvas origin - see _place_child's own doc.
"""
from __future__ import annotations
@@ -178,21 +181,64 @@ def _run_id_of(ctx: RunContext) -> str | None:
return getattr(ctx, "run_id", None)
-def _place_child(document: SceneDocument, parent_id: str | None) -> tuple[float, float]:
+def _anchor_id_of(ctx: RunContext) -> str | None:
+ """stage 8.7: BuilderRunContext already carries plan_node_id (builder.py)
+ for run attribution - reused here as a PLACEMENT reference, not graph
+ parentage, so a build's parentless creates (a note, a from-scratch
+ chat/code node) land near the plan node the user just launched instead
+ of scattered near the canvas origin. Same duck-typed degradation as
+ _run_id_of: a bare RunContext has none, and placement falls through to
+ the origin-drop fallback exactly as before this existed."""
+ return getattr(ctx, "plan_node_id", None)
+
+
+def _place_child(
+ document: SceneDocument, parent_id: str | None, anchor_id: str | None = None,
+) -> tuple[float, float]:
"""Parent-relative placement, model-free: directly below the parent,
fanning right one slot per existing child so parallel children of the
- same parent land side by side instead of stacked."""
- if parent_id is None or parent_id not in document.nodes:
- # Free-floating (note, parentless chat/code): drop near the origin
- # offset by node count so repeated creations don't perfectly overlap.
- n = len(document.nodes)
- return 80.0 + (n % 5) * 40.0, 80.0 + (n % 7) * 40.0
- parent = document.nodes[parent_id]
- existing_children = sum(1 for e in document.edges.values() if e.source == parent_id)
- return (
- parent.x + existing_children * _SIBLING_HORIZONTAL_SPACING,
- parent.y + MESSAGE_VERTICAL_SPACING,
- )
+ same parent land side by side instead of stacked.
+
+ review-fix (stage 8.7): the plan node can be reached by EITHER path -
+ as an explicit parent_id (the executor prompt tells the model the plan
+ node's own id, and nothing stops it passing that id back as parent_id
+ for a chat/code node) or as the implicit anchor for a parentless create.
+ The two branches below used to count siblings differently (edges vs.
+ position), so a parentless create landing via the anchor branch was
+ invisible to the parent branch's edge count and vice versa - two nodes
+ from the two different paths could land on the exact same coordinates.
+ `parent_id != anchor_id` routes that one specific case (parent_id IS the
+ anchor) into the position-based branch below, which sees every node in
+ the row regardless of which path placed it. A normal parent - anything
+ other than the anchor - is completely unaffected."""
+ if parent_id is not None and parent_id in document.nodes and parent_id != anchor_id:
+ parent = document.nodes[parent_id]
+ existing_children = sum(1 for e in document.edges.values() if e.source == parent_id)
+ return (
+ parent.x + existing_children * _SIBLING_HORIZONTAL_SPACING,
+ parent.y + MESSAGE_VERTICAL_SPACING,
+ )
+ reference_id = parent_id or anchor_id
+ if reference_id is not None and reference_id in document.nodes:
+ # The anchor (a plan node) never gains an EDGE to what it builds -
+ # it is a placement reference only - so there is no edge count to
+ # read a sibling index off. A node already sitting on the
+ # reference's own placement row is this same reference's own
+ # earlier creation (nothing else places there), so counting THOSE
+ # stands in for "existing children" without needing a persisted
+ # counter - and, per the docstring above, catches siblings placed
+ # via EITHER path.
+ reference = document.nodes[reference_id]
+ row_y = reference.y + MESSAGE_VERTICAL_SPACING
+ row_siblings = sum(
+ 1 for n in document.nodes.values() if n.x >= reference.x and abs(n.y - row_y) < 1.0
+ )
+ return reference.x + row_siblings * _SIBLING_HORIZONTAL_SPACING, row_y
+ # Free-floating with no anchor either (a bare RunContext, e.g. a future
+ # non-builder caller): drop near the origin offset by node count so
+ # repeated creations don't perfectly overlap.
+ n = len(document.nodes)
+ return 80.0 + (n % 5) * 40.0, 80.0 + (n % 7) * 40.0
def _error(message: str) -> ToolResult:
@@ -215,7 +261,7 @@ async def handler(call: ToolCall, ctx: RunContext) -> ToolResult:
if kind == "document" and not title:
return _error("kind 'document' requires a title.")
- x, y = _place_child(document, parent_id)
+ x, y = _place_child(document, parent_id, _anchor_id_of(ctx))
run_id = _run_id_of(ctx)
def mutator():
diff --git a/contracts/graphlink_scene_payload.py b/contracts/graphlink_scene_payload.py
index ea59de1f..7c20ab1a 100644
--- a/contracts/graphlink_scene_payload.py
+++ b/contracts/graphlink_scene_payload.py
@@ -292,6 +292,21 @@ class PlanStepRow:
detail: str = ""
+@dataclass
+class BuilderActivityRow:
+ """ADR-008 stage 8.7: one row of a Builder run's activity log - the
+ typed wire shape of PlanState.builder_activity's own {"tool","summary",
+ "outcome","stepId","elapsedMs"} dicts (backend/domain/node_states.py).
+ outcome is "ok"|"error" - pinned as a plain string for the same
+ additive-evolution reason PlanStepRow's own status is."""
+
+ tool: str
+ summary: str
+ outcome: str = "ok"
+ stepId: str = ""
+ elapsedMs: int = 0
+
+
@dataclass
class ChartFlowRow:
"""One Sankey flow - the shape canonicalize_chart_data() (graphlink_
@@ -553,6 +568,7 @@ class SceneNodeRow:
# contract these fields serialize.
planGoal: str = ""
planSteps: list["PlanStepRow"] = field(default_factory=list)
+ builderActivity: list["BuilderActivityRow"] = field(default_factory=list)
builderStatus: str = ""
builderMode: str = ""
builderRunId: str = ""
diff --git a/tests/test_node_state_migration.py b/tests/test_node_state_migration.py
index 647596e3..0b1ffe72 100644
--- a/tests/test_node_state_migration.py
+++ b/tests/test_node_state_migration.py
@@ -110,7 +110,7 @@
# candidates - goal/steps/mode/run_id - collide with Command.run_id
# and friends all over the command layer).
"plan": [
- "plan_goal", "plan_steps", "builder_status", "builder_mode",
+ "plan_goal", "plan_steps", "builder_activity", "builder_status", "builder_mode",
"builder_run_id", "builder_max_steps", "builder_max_tokens",
"builder_max_wall_seconds", "builder_spent_steps",
"builder_spent_tokens", "builder_spent_wall_seconds",
@@ -343,9 +343,10 @@ def test_scene_node_core_field_count():
"completionTokens", "promptTokens",
"researchResult", "researchStage", "researchTotal", "responseIncomplete",
"synthesisInstructions", "title", "toolCalls", "x", "y",
- # ADR-008 stage 8.3: the Builder plan node's 15 fields.
- "planGoal", "planSteps", "builderStatus", "builderMode", "builderRunId",
- "builderMaxSteps", "builderMaxTokens", "builderMaxWallSeconds",
+ # ADR-008 stage 8.3: the Builder plan node's 15 fields, +1 (builderActivity)
+ # when stage 8.7 added the run's own activity log.
+ "planGoal", "planSteps", "builderActivity", "builderStatus", "builderMode",
+ "builderRunId", "builderMaxSteps", "builderMaxTokens", "builderMaxWallSeconds",
"builderSpentSteps", "builderSpentTokens", "builderSpentWallSeconds",
"builderAwaitingToolApproval", "builderApprovalToolName",
"builderApprovalSummary", "builderStatusDetail",
diff --git a/tests/test_undo_classification_gate.py b/tests/test_undo_classification_gate.py
index 63e6da92..dc4327c7 100644
--- a/tests/test_undo_classification_gate.py
+++ b/tests/test_undo_classification_gate.py
@@ -255,11 +255,12 @@ def test_the_scan_finds_the_real_population_of_registered_intents():
# archiveWorkspace sextet, 165 -> 166 when ADR-020 stage 20.3 added
# app-chat-library's own setWorkspaceDefaultModel, 166 -> 168 when
# ADR-020 stage 20.4 added app-chat-library's own loadGraphAndFocusNode
- # plus the new "globalSearch" topic's own search intent, and 168 -> 169
- # when ADR-020 stage 20.5 added app-chat-library's own exportWorkspace.
+ # plus the new "globalSearch" topic's own search intent, 168 -> 169
+ # when ADR-020 stage 20.5 added app-chat-library's own exportWorkspace,
+ # and 169 -> 170 when ADR-008 stage 8.7 added builder's own deleteRecipe.
real = _collect_real_registrations()
- assert len(real) == 169, (
- f"expected exactly 169 real registered intents, found {len(real)} - "
+ assert len(real) == 170, (
+ f"expected exactly 170 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 b9576358..b2508760 100644
--- a/tests/undo_classification.py
+++ b/tests/undo_classification.py
@@ -248,6 +248,7 @@ class Classified:
Classified("builder", "denyTool", "B", "security: builder tool-call approval gate"),
Classified("builder", "listRecipes", "B", "read-only: returns the recipe list"),
Classified("builder", "saveRecipe", "B", "preference: writes the settings store's recipe list, not document state"),
+ Classified("builder", "deleteRecipe", "B", "preference: writes the settings store's recipe list, not document state - same posture as saveRecipe"),
Classified("scene", "setPlanSteps", "A", "content: the plan checklist is document state"),
# -- backend/api/intents_settings_general.py (app-settings) -------------
diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx
index 0381c797..0372d14a 100644
--- a/web_ui/src/app/App.tsx
+++ b/web_ui/src/app/App.tsx
@@ -505,7 +505,7 @@ function App() {
-
+
diff --git a/web_ui/src/app/canvas/PlanNodeView.test.tsx b/web_ui/src/app/canvas/PlanNodeView.test.tsx
index c556d011..b2e3479a 100644
--- a/web_ui/src/app/canvas/PlanNodeView.test.tsx
+++ b/web_ui/src/app/canvas/PlanNodeView.test.tsx
@@ -1,3 +1,4 @@
+import type { ReactElement } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReactFlowProvider } from "@xyflow/react";
@@ -15,6 +16,7 @@ function makeData(overrides: Partial = {}): PlanNodeData {
{ id: "s2", title: "Write summary", status: "running", detail: "" },
{ id: "s3", title: "Chart it", status: "pending", detail: "" },
],
+ builderActivity: [],
builderStatus: "running",
builderMode: "copilot",
builderRunId: "run-1",
@@ -42,8 +44,8 @@ function makeData(overrides: Partial = {}): PlanNodeData {
};
}
-function renderPlan(data: PlanNodeData) {
- return render(
+function planElement(data: PlanNodeData) {
+ return (
- ,
+
);
}
+function renderPlan(data: PlanNodeData) {
+ return render(planElement(data));
+}
+
+// ReactFlowProvider remounting on every rerender would reset internal state
+// unrelated to this test - reusing the exact same element shape as
+// renderPlan keeps rerender() a like-for-like prop update instead.
+function rerenderPlan(rerender: (ui: ReactElement) => void, data: PlanNodeData) {
+ rerender(planElement(data));
+}
+
describe("PlanNodeView", () => {
it("renders the goal, every step with its status, and the budget line", () => {
renderPlan(makeData());
@@ -183,4 +196,84 @@ describe("PlanNodeView", () => {
expect(screen.getByRole("alert")).toHaveTextContent("the model aborted");
expect(screen.getByText("autopilot")).toBeInTheDocument();
});
+
+ describe("activity log", () => {
+ it("stays hidden entirely when the build has no activity yet", () => {
+ renderPlan(makeData({ builderActivity: [] }));
+ expect(screen.queryByText(/activity entr/)).not.toBeInTheDocument();
+ });
+
+ it("shows the count, error count, and every row's tool/summary/elapsed - collapsed by default", () => {
+ const data = makeData({
+ builderActivity: [
+ { tool: "graph.create_node", summary: '{"kind":"note"}', outcome: "ok", stepId: "s1", elapsedMs: 12 },
+ {
+ tool: "graph.create_node",
+ summary: "Tool call 'graph.create_node' was denied approval.",
+ outcome: "error", stepId: "s1", elapsedMs: 0,
+ },
+ ],
+ });
+ renderPlan(data);
+
+ const summary = screen.getByText("2 activity entries · 1 error");
+ const details = summary.closest("details");
+ expect(details).not.toHaveAttribute("open");
+ expect(screen.getAllByText("graph.create_node")).toHaveLength(2);
+ expect(screen.getByText('{"kind":"note"}')).toBeInTheDocument();
+ expect(screen.getByText(/was denied approval/)).toBeInTheDocument();
+ expect(screen.getByText("12ms")).toBeInTheDocument();
+ });
+
+ it("tints an error row distinctly from an ok row", () => {
+ renderPlan(makeData({
+ builderActivity: [
+ { tool: "graph.create_node", summary: "ok call", outcome: "ok", stepId: "s1", elapsedMs: 5 },
+ { tool: "run_node", summary: "boom", outcome: "error", stepId: "s1", elapsedMs: 3 },
+ ],
+ }));
+
+ expect(screen.getByText("ok call").closest(".chat-node-tool-invocation")).not.toHaveClass("error");
+ expect(screen.getByText("boom").closest(".chat-node-tool-invocation")).toHaveClass("error");
+ });
+
+ it("singular-cases one entry and omits the error count when nothing failed", () => {
+ renderPlan(makeData({
+ builderActivity: [
+ { tool: "builder.complete_step", summary: "{}", outcome: "ok", stepId: "s1", elapsedMs: 1 },
+ ],
+ }));
+ expect(screen.getByText("1 activity entry")).toBeInTheDocument();
+ expect(screen.queryByText(/error/)).not.toBeInTheDocument();
+ });
+
+ it("review-fix: only auto-scrolls while running AND the disclosure is open - not collapsed, not landed", () => {
+ const scrollToSpy = vi.fn();
+ const originalScrollTo = Element.prototype.scrollTo;
+ Element.prototype.scrollTo = scrollToSpy;
+ const row = (n: number) => ({ tool: `tool-${n}`, summary: "{}", outcome: "ok", stepId: "s1", elapsedMs: n });
+
+ try {
+ const { rerender } = renderPlan(makeData({ builderStatus: "running", builderActivity: [row(1)] }));
+ const details = document.querySelector(".plan-node-activity") as HTMLDetailsElement;
+ expect(details.open).toBe(false); // collapsed by default
+
+ rerenderPlan(rerender, makeData({ builderStatus: "running", builderActivity: [row(1), row(2)] }));
+ expect(scrollToSpy).not.toHaveBeenCalled(); // still collapsed - must not scroll a hidden panel
+
+ details.open = true; // the same native toggle a real click on performs
+ rerenderPlan(rerender, makeData({ builderStatus: "running", builderActivity: [row(1), row(2), row(3)] }));
+ expect(scrollToSpy).toHaveBeenCalledTimes(1); // running + open - follows the newest row
+
+ scrollToSpy.mockClear();
+ rerenderPlan(rerender, makeData({
+ builderStatus: "done", pendingRequestId: null,
+ builderActivity: [row(1), row(2), row(3), row(4)],
+ }));
+ expect(scrollToSpy).not.toHaveBeenCalled(); // landed - stops following, doesn't yank a spot the user scrolled to
+ } finally {
+ Element.prototype.scrollTo = originalScrollTo;
+ }
+ });
+ });
});
diff --git a/web_ui/src/app/canvas/PlanNodeView.tsx b/web_ui/src/app/canvas/PlanNodeView.tsx
index 34c61fc8..5bad6d55 100644
--- a/web_ui/src/app/canvas/PlanNodeView.tsx
+++ b/web_ui/src/app/canvas/PlanNodeView.tsx
@@ -27,9 +27,18 @@ export interface PlanStepData {
detail: string;
}
+export interface BuilderActivityRowData {
+ tool: string;
+ summary: string;
+ outcome: string;
+ stepId: string;
+ elapsedMs: number;
+}
+
export interface PlanNodeData extends Record {
planGoal: string;
planSteps: PlanStepData[];
+ builderActivity: BuilderActivityRowData[];
builderStatus: string;
builderMode: string;
builderRunId: string;
@@ -96,6 +105,21 @@ function PlanNodeViewInner({ data, selected }: NodeProps) {
const resumable = RESUMABLE.has(data.builderStatus);
const startLabel = data.builderStatus === "awaiting_start" ? "Start build" : "Resume";
const denyButtonRef = useRef(null);
+ const activityDetailsRef = useRef(null);
+ const activityListRef = useRef(null);
+ const activityErrorCount = data.builderActivity.filter((row) => row.outcome !== "ok").length;
+
+ // Keeps the log pinned to its newest row while the build is actively
+ // producing more of them - only while the disclosure is actually open
+ // (a native 's own `open` attribute IS the expand/collapse
+ // state here, so no separate React state exists to gate this on) and
+ // only while running, so a landed build's log stops moving under the
+ // user once there is nothing left to follow.
+ useEffect(() => {
+ if (running && activityDetailsRef.current?.open) {
+ activityListRef.current?.scrollTo({ top: activityListRef.current.scrollHeight });
+ }
+ }, [data.builderActivity, running]);
// The tool-approval panel mounts fresh each time the Builder pauses for
// approval (see the block-level comment above); Deny is the safe default,
@@ -166,6 +190,40 @@ function PlanNodeViewInner({ data, selected }: NodeProps) {
+ {/* stage 8.7: the build's own visible record of what it did - real
+ backend state (PlanState.builder_activity), not a debug aid.
+ Reuses ChatNodeView's own "an assistant turn's tool calls,
+ disclosed" pattern (.chat-node-tool-invocations) rather than a
+ parallel widget, since the content is the same shape (a tool
+ name, an outcome, one block of detail text) - just scoped to a
+ whole BUILD instead of one turn, and potentially many more rows,
+ which is the one thing that needs its own scrollable container. */}
+ {data.builderActivity.length > 0 && (
+
+
+ {data.builderActivity.length === 1
+ ? "1 activity entry"
+ : `${data.builderActivity.length} activity entries`}
+ {activityErrorCount > 0 &&
+ ` · ${activityErrorCount} error${activityErrorCount === 1 ? "" : "s"}`}
+
+
+ {data.builderActivity.map((row, index) => (
+
+
+ {row.tool}
+ {row.elapsedMs}ms
+
+
{row.summary}
+
+ ))}
+
+
+ )}
+
{data.builderAwaitingToolApproval && (
diff --git a/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx b/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx
index 83044bb5..ce37363c 100644
--- a/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx
+++ b/web_ui/src/app/canvas/SceneCanvas.pinSearchJump.test.tsx
@@ -131,6 +131,7 @@ function chatRow(id: string, x: number, y: number, title = id): SceneNodeRow {
indexIntoKnowledge: false,
planGoal: "",
planSteps: [],
+ builderActivity: [],
builderStatus: "",
builderMode: "",
builderRunId: "",
diff --git a/web_ui/src/app/canvas/SceneCanvas.test.tsx b/web_ui/src/app/canvas/SceneCanvas.test.tsx
index 4d821689..bfa01bbe 100644
--- a/web_ui/src/app/canvas/SceneCanvas.test.tsx
+++ b/web_ui/src/app/canvas/SceneCanvas.test.tsx
@@ -141,6 +141,7 @@ function baseNode(overrides: Partial = {}): SceneNodeRow {
indexIntoKnowledge: false,
planGoal: "",
planSteps: [],
+ builderActivity: [],
builderStatus: "",
builderMode: "",
builderRunId: "",
diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx
index a08a915f..6b67e0ff 100644
--- a/web_ui/src/app/canvas/SceneCanvas.tsx
+++ b/web_ui/src/app/canvas/SceneCanvas.tsx
@@ -1439,6 +1439,7 @@ export function toFlowNodes(
data: {
planGoal: n.planGoal,
planSteps: n.planSteps,
+ builderActivity: n.builderActivity,
builderStatus: n.builderStatus,
builderMode: n.builderMode,
builderRunId: n.builderRunId,
diff --git a/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx b/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx
index 063c67eb..e99edf5f 100644
--- a/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx
+++ b/web_ui/src/app/canvas/SceneCanvas.virtualization.test.tsx
@@ -150,6 +150,7 @@ function chatRow(id: string, x: number, y = 0): SceneNodeRow {
indexIntoKnowledge: false,
planGoal: "",
planSteps: [],
+ builderActivity: [],
builderStatus: "",
builderMode: "",
builderRunId: "",
diff --git a/web_ui/src/app/canvas/renderCountGate.test.tsx b/web_ui/src/app/canvas/renderCountGate.test.tsx
index c29663e0..bb4c22b4 100644
--- a/web_ui/src/app/canvas/renderCountGate.test.tsx
+++ b/web_ui/src/app/canvas/renderCountGate.test.tsx
@@ -149,6 +149,7 @@ function chatRow(id: string, x: number): SceneNodeRow {
indexIntoKnowledge: false, // ADR-017 stage 17.5
planGoal: "",
planSteps: [],
+ builderActivity: [],
builderStatus: "",
builderMode: "",
builderRunId: "",
diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts
index 40b7ce95..7dd83c49 100644
--- a/web_ui/src/app/canvas/sceneStore.test.ts
+++ b/web_ui/src/app/canvas/sceneStore.test.ts
@@ -186,6 +186,7 @@ function validScenePayload(overrides: Record = {}) {
indexIntoKnowledge: false,
planGoal: "",
planSteps: [],
+ builderActivity: [],
builderStatus: "",
builderMode: "",
builderRunId: "",
diff --git a/web_ui/src/app/chrome/BuilderLaunchDialog.test.tsx b/web_ui/src/app/chrome/BuilderLaunchDialog.test.tsx
index 92ba3927..d85d826b 100644
--- a/web_ui/src/app/chrome/BuilderLaunchDialog.test.tsx
+++ b/web_ui/src/app/chrome/BuilderLaunchDialog.test.tsx
@@ -1,6 +1,38 @@
+/**
+ * stage 8.7 rebuild. Mirrors GlobalSearchDialog.test.tsx's own useReactFlow
+ * wrapping (every real pan/zoom export stays functional; only setCenter is
+ * intercepted) for asserting the post-launch focus call's exact arguments,
+ * and CustomSelect.test.tsx's own "click the trigger by its ariaLabel, then
+ * click the option by its label" interaction shape for the recipe picker -
+ * userEvent.selectOptions no longer applies now that this is not a native
+ *