diff --git a/.agent/design.md b/.agent/design.md index 0f68d23bf..e8cef12fc 100644 --- a/.agent/design.md +++ b/.agent/design.md @@ -6,6 +6,8 @@ These rules govern architectural decisions. When adding a feature or fixing a bu New capabilities should be added via `channels/`, `tools/`, skills, or MCP servers. The files `agent/loop.py` and `agent/runner.py` form the critical core path; changes there should be minimal and justified. If a feature can live in a channel adapter, a tool, or an external MCP server, it should not be inlined into the agent loop. +Runtime state fan-out follows the same boundary. `AgentLoop` may publish generic runtime events from `nanobot.bus.runtime_events` for turn/run/model/goal state changes, but WebUI/WebSocket wire details such as `_turn_end`, `_goal_status`, title refreshes, and goal-state sync belong in `nanobot.session.webui_turns.WebuiTurnCoordinator` or the relevant channel adapter. + ## Less structure, more intelligence Prefer simple, readable code over new framework layers and indirection. Add structure only when it removes real complexity, protects an important boundary, or matches an established local pattern. The best fix is often a smaller prompt, a tighter tool contract, a channel-local change, or one focused regression test. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d010118e3..ddc7399a1 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -45,13 +45,13 @@ from nanobot.security.workspace_access import ( bind_workspace_scope, reset_workspace_scope, ) +from nanobot.session import turn_continuation from nanobot.session.goal_state import ( goal_state_runtime_lines, runner_wall_llm_timeout_s, sustained_goal_active, ) from nanobot.session.manager import Session, SessionManager -from nanobot.session import turn_continuation from nanobot.utils.document import extract_documents, reference_non_image_attachments from nanobot.utils.helpers import image_placeholder_text from nanobot.utils.helpers import truncate_text as truncate_text_fn @@ -1017,6 +1017,13 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content="Sorry, I encountered an error.", )) + if not turn_continuation.internal_continuation_pending(msg.metadata): + await self._runtime_events().turn_completed( + channel=msg.channel, + chat_id=msg.chat_id, + session_key=session_key, + metadata=msg.metadata, + ) finally: # Drain any messages still in the pending queue and re-publish # them to the bus so they are processed as fresh inbound messages diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 08cfecb1d..bbac2e6af 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -576,6 +576,45 @@ class TestToolEventProgress: assert turn_end_msgs[0].chat_id == "chat1" assert outbound.index(done_msgs[0]) < outbound.index(turn_end_msgs[0]) + @pytest.mark.asyncio + async def test_websocket_dispatch_publishes_turn_end_after_error( + self, + tmp_path: Path, + ) -> None: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") + _attach_webui_runtime_events(loop, bus) + + async def raise_from_turn(*_args, **_kwargs): + raise RuntimeError("boom") + + loop._process_message = raise_from_turn # type: ignore[method-assign] + + await loop._dispatch(InboundMessage( + channel="websocket", + sender_id="u1", + chat_id="chat1", + content="say hello", + )) + + outbound = [] + while bus.outbound_size > 0: + outbound.append(await bus.consume_outbound()) + + error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."] + turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")] + statuses = [m for m in outbound if m.metadata.get("_goal_status")] + + assert len(error_msgs) == 1 + assert len(turn_end_msgs) == 1 + assert turn_end_msgs[0].content == "" + assert turn_end_msgs[0].chat_id == "chat1" + assert [m.metadata["goal_status"] for m in statuses] == ["idle"] + assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0]) + assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1]) + @pytest.mark.asyncio async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None: bus = MessageBus()