"""Tests for sustained goal tools (``create_goal``, ``update_goal``).""" from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission from nanobot.agent.loop import AgentLoop from nanobot.agent.tools.context import ( RequestContext, current_request_context, request_context, ) from nanobot.agent.tools.long_task import ( CreateGoalTool, UpdateGoalTool, ) from nanobot.agent.tools.registry import ToolRegistry from nanobot.bus.outbound_events import GoalStateSyncEvent from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.session.goal_state import GOAL_STATE_KEY, MAX_GOAL_OBJECTIVE_CHARS from nanobot.session.manager import SessionManager from nanobot.session.turn_continuation import should_finalize_on_max_iterations from nanobot.session.webui_turns import WebuiTurnCoordinator def _goal_metadata() -> dict[str, object]: return { "original_command": "/goal", "original_content": "/goal implement the agreed plan", "goal_requested": True, } def _request_context( *, chat_id: str = "c1", metadata: dict[str, object] | None = None, original_user_text: str | None = "/goal implement the agreed plan", channel: str = "websocket", ) -> RequestContext: return RequestContext( channel=channel, chat_id=chat_id, session_key=f"{channel}:{chat_id}", original_user_text=original_user_text, metadata=metadata if metadata is not None else _goal_metadata(), ) def _tools( sm: SessionManager, *, metadata: dict[str, object] | None = None, ) -> tuple[CreateGoalTool, UpdateGoalTool, RequestContext]: create = CreateGoalTool(sessions=sm) update = UpdateGoalTool(sessions=sm) rc = _request_context(metadata=metadata) return create, update, rc async def _execute(tool, ctx: RequestContext, *, allowed: bool = True, **kwargs): with request_context(ctx), goal_mutation_permission(allowed): return await tool.execute(**kwargs) @pytest.mark.asyncio async def test_create_goal_records_goal_metadata(tmp_path): sm = SessionManager(tmp_path) create, _update, ctx = _tools(sm) sm.get_or_create("websocket:c1").metadata["_sustained_goal_continuation_rounds"] = 12 out = await _execute( create, ctx, objective="Do the thing", ui_summary="thing", ) assert "Goal recorded" in out sess = sm.get_or_create("websocket:c1") blob = sess.metadata.get(GOAL_STATE_KEY) assert isinstance(blob, dict) assert blob["status"] == "active" assert blob["objective"] == "Do the thing" assert blob["ui_summary"] == "thing" assert "_sustained_goal_continuation_rounds" not in sess.metadata assert "_sustained_goal_continuation_rounds" not in ( SessionManager(tmp_path).get_or_create("websocket:c1").metadata ) assert not should_finalize_on_max_iterations( pending_queue_available=True, session_metadata=sess.metadata, ) @pytest.mark.asyncio async def test_create_goal_rejects_without_explicit_goal_permission(tmp_path): sm = SessionManager(tmp_path) create, _update, ctx = _tools(sm) sess = sm.get_or_create("websocket:c1") sess.add_message("user", "/goal implement the old plan") sess.add_message("assistant", "The old goal is complete.") sess.add_message("user", "Handle this as an ordinary one-time task.") out = await _execute( create, ctx, allowed=False, objective="Implement another plan.", ) assert "create_goal is unavailable for this turn" in str(out) assert "/goal " in str(out) assert GOAL_STATE_KEY not in sess.metadata @pytest.mark.asyncio async def test_update_goal_complete_closes_active_goal(tmp_path): sm = SessionManager(tmp_path) create, update, ctx = _tools(sm) with request_context(ctx), goal_mutation_permission(True): await create.execute(objective="X") out = await update.execute(action="complete", recap="Done.") denied = await create.execute(objective="Another") assert goal_mutation_allowed() is False assert "marked complete" in out assert "create_goal is unavailable for this turn" in str(denied) sess = sm.get_or_create("websocket:c1") blob = sess.metadata.get(GOAL_STATE_KEY) assert blob["status"] == "completed" assert blob["recap"] == "Done." @pytest.mark.asyncio async def test_update_goal_replace_keeps_goal_active_with_new_objective(tmp_path): sm = SessionManager(tmp_path) create, update, ctx = _tools(sm) await _execute(create, ctx, objective="Old") sess = sm.get_or_create("websocket:c1") sess.metadata["_sustained_goal_continuation_rounds"] = 12 sm.save(sess) out = await _execute( update, _request_context(), action="replace", objective="New", ui_summary="new", ) assert "Goal replaced" in out blob = sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY] assert blob["status"] == "active" assert blob["objective"] == "New" assert blob["previous_objective"] == "Old" assert blob["ui_summary"] == "new" assert "_sustained_goal_continuation_rounds" not in sess.metadata assert "_sustained_goal_continuation_rounds" not in ( SessionManager(tmp_path).get_or_create("websocket:c1").metadata ) assert not should_finalize_on_max_iterations( pending_queue_available=True, session_metadata=sess.metadata, ) @pytest.mark.asyncio async def test_goal_state_mutations_roll_back_on_save_failure(tmp_path, monkeypatch): sm = SessionManager(tmp_path) create, update, _context = _tools(sm) sess = sm.get_or_create("websocket:c1") sess.metadata["marker"] = {"keep": True} sess.metadata["_sustained_goal_continuation_rounds"] = 12 original_save = sm.save create_context = _request_context() def fail_save(_session, **_kwargs): raise OSError("disk unavailable") monkeypatch.setattr(sm, "save", fail_save) with pytest.raises(OSError, match="disk unavailable"): await _execute(create, create_context, objective="Old") assert sess.metadata == { "marker": {"keep": True}, "_sustained_goal_continuation_rounds": 12, } assert GOAL_STATE_KEY not in SessionManager(tmp_path).get_or_create("websocket:c1").metadata monkeypatch.setattr(sm, "save", original_save) assert "Goal recorded" in await _execute(create, create_context, objective="Old") sess.metadata["_sustained_goal_continuation_rounds"] = 12 sm.save(sess) replace_context = _request_context() monkeypatch.setattr(sm, "save", fail_save) with pytest.raises(OSError, match="disk unavailable"): await _execute(update, replace_context, action="replace", objective="New") assert sess.metadata[GOAL_STATE_KEY]["objective"] == "Old" assert sess.metadata["_sustained_goal_continuation_rounds"] == 12 persisted = SessionManager(tmp_path).get_or_create("websocket:c1").metadata assert persisted[GOAL_STATE_KEY]["objective"] == "Old" assert persisted["_sustained_goal_continuation_rounds"] == 12 monkeypatch.setattr(sm, "save", original_save) assert "Goal replaced" in await _execute( update, replace_context, action="replace", objective="New", ) assert "_sustained_goal_continuation_rounds" not in sess.metadata assert ( SessionManager(tmp_path).get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["objective"] == "New" ) @pytest.mark.asyncio async def test_goal_tools_reject_oversized_objectives(tmp_path): sm = SessionManager(tmp_path) create = CreateGoalTool(sessions=sm) create_context = _request_context() oversized = "x" * (MAX_GOAL_OBJECTIVE_CHARS + 1) create_out = await _execute(create, create_context, objective=oversized) assert f"must not exceed {MAX_GOAL_OBJECTIVE_CHARS}" in str(create_out) assert GOAL_STATE_KEY not in sm.get_or_create("websocket:c1").metadata assert "Goal recorded" in await _execute( create, create_context, objective="x" * MAX_GOAL_OBJECTIVE_CHARS, ) update = UpdateGoalTool(sessions=sm) replace_context = _request_context() replace_out = await _execute(update, replace_context, action="replace", objective=oversized) assert f"must not exceed {MAX_GOAL_OBJECTIVE_CHARS}" in str(replace_out) assert len(sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["objective"]) == ( MAX_GOAL_OBJECTIVE_CHARS ) @pytest.mark.asyncio async def test_active_goal_create_failure_preserves_permission_for_replace(tmp_path): sm = SessionManager(tmp_path) create, update, initial_context = _tools(sm) assert "Goal recorded" in await _execute(create, initial_context, objective="Old") replacement_context = _request_context() with request_context(replacement_context), goal_mutation_permission(True): create_out = await create.execute(objective="New") assert goal_mutation_allowed() is True replace_out = await update.execute(action="replace", objective="New") assert goal_mutation_allowed() is True assert "already active" in str(create_out) assert "Goal replaced" in replace_out @pytest.mark.asyncio async def test_update_goal_replace_requires_explicit_goal_permission(tmp_path): sm = SessionManager(tmp_path) create, update, initial_context = _tools(sm) assert "Goal recorded" in await _execute(create, initial_context, objective="Old") ordinary_context = _request_context(original_user_text="Continue the existing objective.") unauthorized = await _execute( update, ordinary_context, allowed=False, action="replace", objective="Unrequested", ) assert "replacing the goal is unavailable for this turn" in str(unauthorized) assert "/goal " in str(unauthorized) assert sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["objective"] == "Old" replace_context = _request_context() with request_context(replace_context), goal_mutation_permission(True): assert "Goal replaced" in await update.execute(action="replace", objective="New") reused = await update.execute(action="replace", objective="Another") assert goal_mutation_allowed() is True assert "Goal replaced" in reused assert sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["objective"] == "Another" @pytest.mark.asyncio async def test_goal_tools_keep_request_context_per_task(tmp_path): sm = SessionManager(tmp_path) create = CreateGoalTool(sessions=sm) update = UpdateGoalTool(sessions=sm) ctx_a = RequestContext( channel="websocket", chat_id="a", session_key="websocket:a", metadata=_goal_metadata(), ) ctx_b = RequestContext( channel="websocket", chat_id="b", session_key="websocket:b", metadata=_goal_metadata(), ) task_a = asyncio.create_task(_execute(create, ctx_a, objective="Goal A")) task_b = asyncio.create_task(_execute(create, ctx_b, objective="Goal B")) await asyncio.gather(task_a, task_b) assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["objective"] == "Goal A" assert sm.get_or_create("websocket:b").metadata[GOAL_STATE_KEY]["objective"] == "Goal B" a_revoked = asyncio.Event() async def complete_a() -> None: with request_context(ctx_a), goal_mutation_permission(True): await update.execute(action="complete", recap="Done A") assert goal_mutation_allowed() is False a_revoked.set() async def replace_b() -> None: with request_context(ctx_b), goal_mutation_permission(True): await a_revoked.wait() assert goal_mutation_allowed() is True await update.execute(action="replace", objective="Goal B2") await asyncio.gather(complete_a(), replace_b()) assert sm.get_or_create("websocket:a").metadata[GOAL_STATE_KEY]["recap"] == "Done A" assert sm.get_or_create("websocket:b").metadata[GOAL_STATE_KEY]["objective"] == "Goal B2" @pytest.mark.asyncio async def test_registry_does_not_reuse_goal_context_after_request_scope(tmp_path): sm = SessionManager(tmp_path) create = CreateGoalTool(sessions=sm) update = UpdateGoalTool(sessions=sm) registry = ToolRegistry() registry.register(create) registry.register(update) sess = sm.get_or_create("websocket:c1") sess.metadata[GOAL_STATE_KEY] = {"status": "active", "objective": "Old"} sm.save(sess) ctx = _request_context() with request_context(ctx), goal_mutation_permission(True): create_out = await registry.execute("create_goal", {"objective": "New"}) complete_out = await registry.execute( "update_goal", {"action": "complete", "recap": "Old goal done."}, ) denied_out = await registry.execute("create_goal", {"objective": "Denied"}) assert goal_mutation_allowed() is False assert "already active" in str(create_out) assert "marked complete" in str(complete_out) assert "create_goal is unavailable for this turn" in str(denied_out) assert current_request_context() is None leaked_out = await registry.execute("create_goal", {"objective": "Leaked"}) assert "missing routing context" in str(leaked_out) assert sess.metadata[GOAL_STATE_KEY]["status"] == "completed" @pytest.mark.asyncio async def test_goal_state_events_publish_active_then_inactive(tmp_path): bus = MagicMock() bus.publish_outbound = AsyncMock() runtime_events = RuntimeEventBus() sm = SessionManager(tmp_path) WebuiTurnCoordinator( bus=bus, sessions=sm, schedule_background=lambda _coro: None, ).subscribe(runtime_events) create = CreateGoalTool(sessions=sm, runtime_events=runtime_events) update = UpdateGoalTool(sessions=sm, runtime_events=runtime_events) rc = _request_context(chat_id="chat-99") await _execute( create, rc, objective="Objective alpha", ui_summary="alpha", ) bus.publish_outbound.assert_awaited_once() call = bus.publish_outbound.await_args.args[0] assert call.channel == "websocket" assert call.chat_id == "chat-99" assert isinstance(call.event, GoalStateSyncEvent) assert call.event.goal_state == { "active": True, "ui_summary": "alpha", "objective": "Objective alpha", } bus.publish_outbound.reset_mock() await _execute( update, RequestContext( channel="websocket", chat_id="chat-99", session_key="websocket:chat-99", ), action="complete", recap="Done.", ) bus.publish_outbound.assert_awaited_once() call = bus.publish_outbound.await_args.args[0] assert isinstance(call.event, GoalStateSyncEvent) assert call.event.goal_state == {"active": False} @pytest.mark.asyncio async def test_update_goal_without_active_is_noop_message(tmp_path): sm = SessionManager(tmp_path) _create, update, ctx = _tools(sm) out = await _execute(update, ctx, action="complete", recap="n/a") assert "No active" in out @pytest.mark.asyncio async def test_goal_tools_registered_in_base_registry(tmp_path): bus = MessageBus() provider = MagicMock() provider.get_default_model.return_value = "test-model" loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") create = loop.tools.get("create_goal") update = loop.tools.get("update_goal") assert create is not None and create.name == "create_goal" assert update is not None and update.name == "update_goal" assert set(create.parameters["properties"]) == {"objective", "ui_summary"} assert create.parameters["required"] == ["objective"] assert ( create.parameters["properties"]["objective"]["maxLength"] == MAX_GOAL_OBJECTIVE_CHARS ) assert ( update.parameters["properties"]["objective"]["maxLength"] == MAX_GOAL_OBJECTIVE_CHARS ) model_visible_contract = " ".join( ( create.description, str(create.parameters), update.description, str(update.parameters), ) ).lower() assert "authoriz" not in model_visible_contract assert "/goal" not in model_visible_contract