| 1 | from __future__ import annotations |
| 2 | |
| 3 | import base64 |
| 4 | import shutil |
| 5 | import subprocess |
| 6 | import uuid |
| 7 | from pathlib import Path |
| 8 | from types import SimpleNamespace |
| 9 | |
| 10 | import pytest |
| 11 | |
| 12 | from agent import Agent, LoopData |
| 13 | from helpers import extension, files, mcp_handler |
| 14 | from helpers.llm_result import LLMResult |
| 15 | from helpers.log import Log |
| 16 | from plugins._goal.api.goal import Goal as GoalApi |
| 17 | from plugins._goal.commands import goal_command |
| 18 | from plugins._goal.tools import goal |
| 19 | from plugins._goal.tools.goal import GoalTool |
| 20 | from plugins._goal.tools.response import ResponseTool |
| 21 | |
| 22 | |
| 23 | @pytest.fixture() |
| 24 | def context_id(): |
| 25 | context_id = f"goal-test-{uuid.uuid4().hex}" |
| 26 | yield context_id |
| 27 | goal.delete_goal(context_id) |
| 28 | |
| 29 | |
| 30 | def _payload(context_id: str, command_text: str) -> dict: |
| 31 | from plugins._commands.helpers.commands import parse_slash_invocation |
| 32 | |
| 33 | return { |
| 34 | "invocation": parse_slash_invocation(command_text), |
| 35 | "context": {"context_id": context_id}, |
| 36 | } |
| 37 | |
| 38 | |
| 39 | def test_goal_storage_round_trip(context_id: str): |
| 40 | current_goal = goal.create_goal(context_id, "Ship the goal plugin", token_budget=1200) |
| 41 | |
| 42 | loaded = goal.get_goal(context_id) |
| 43 | assert loaded == current_goal |
| 44 | assert loaded["status"] == "active" |
| 45 | assert loaded["token_budget"] == 1200 |
| 46 | assert loaded["active_since"] |
| 47 | assert loaded["elapsed_seconds"] == 0 |
| 48 | |
| 49 | updated = goal.update_goal(context_id, status="paused", objective="Polish the goal strip") |
| 50 | assert updated["status"] == "paused" |
| 51 | assert updated["objective"] == "Polish the goal strip" |
| 52 | assert updated["active_since"] == "" |
| 53 | paused_seconds = updated["elapsed_seconds"] |
| 54 | |
| 55 | resumed = goal.update_goal(context_id, status="active") |
| 56 | assert resumed["status"] == "active" |
| 57 | assert resumed["active_since"] |
| 58 | assert resumed["elapsed_seconds"] == paused_seconds |
| 59 | |
| 60 | goal.delete_goal(context_id) |
| 61 | assert goal.get_goal(context_id) is None |
| 62 | |
| 63 | |
| 64 | def test_goal_changes_publish_state_revision(context_id: str, monkeypatch): |
| 65 | from agent import AgentContext |
| 66 | from helpers import state_monitor_integration |
| 67 | |
| 68 | revisions = iter([1.0, 2.0, 3.0]) |
| 69 | output_data = {} |
| 70 | dirty = [] |
| 71 | context = SimpleNamespace( |
| 72 | set_output_data=lambda key, value: output_data.__setitem__(key, value) |
| 73 | ) |
| 74 | monkeypatch.setattr(AgentContext, "get", lambda _context_id: context) |
| 75 | monkeypatch.setattr(goal.time, "time", lambda: next(revisions)) |
| 76 | monkeypatch.setattr( |
| 77 | state_monitor_integration, |
| 78 | "mark_dirty_for_context", |
| 79 | lambda context_id, *, reason: dirty.append((context_id, reason)), |
| 80 | ) |
| 81 | |
| 82 | goal.create_goal(context_id, "Publish changes") |
| 83 | goal.update_goal(context_id, status="paused") |
| 84 | goal.delete_goal(context_id) |
| 85 | |
| 86 | assert output_data["_goal_revision"] == 3.0 |
| 87 | assert dirty == [(context_id, "plugins._goal")] * 3 |
| 88 | |
| 89 | |
| 90 | def test_goal_webui_uses_state_revisions_instead_of_polling(): |
| 91 | plugin_root = Path(__file__).resolve().parents[1] |
| 92 | store = (plugin_root / "webui" / "goal-store.js").read_text() |
| 93 | strip = ( |
| 94 | plugin_root |
| 95 | / "extensions" |
| 96 | / "webui" |
| 97 | / "chat-input-progress-start" |
| 98 | / "goal-strip.html" |
| 99 | ).read_text() |
| 100 | refresh = ( |
| 101 | plugin_root |
| 102 | / "extensions" |
| 103 | / "webui" |
| 104 | / "apply_snapshot_before" |
| 105 | / "refresh-goal.js" |
| 106 | ).read_text() |
| 107 | |
| 108 | assert "setInterval(() => this.refresh" not in store |
| 109 | assert "$watch('$store.chats.selected'" not in strip |
| 110 | assert "_goal_revision" in refresh |
| 111 | assert "goalStore.refresh(true)" in refresh |
| 112 | |
| 113 | |
| 114 | def test_goal_composer_menu_prefills_without_sending(): |
| 115 | plugin_root = Path(__file__).resolve().parents[1] |
| 116 | injector = ( |
| 117 | plugin_root |
| 118 | / "extensions" |
| 119 | / "webui" |
| 120 | / "initFw_end" |
| 121 | / "goal-menu-injector.js" |
| 122 | ).read_text() |
| 123 | |
| 124 | assert 'chatInputStore.message = "/goal ";' in injector |
| 125 | assert "chatInputStore.focus();" in injector |
| 126 | assert "sendMessage" not in injector |
| 127 | |
| 128 | |
| 129 | @pytest.mark.skipif(not shutil.which("node"), reason="node is required") |
| 130 | def test_goal_webui_uses_shared_hour_aware_duration_formatter(): |
| 131 | project_root = Path(__file__).resolve().parents[3] |
| 132 | time_utils = (project_root / "webui" / "js" / "time-utils.js").read_bytes() |
| 133 | module_url = "data:text/javascript;base64," + base64.b64encode(time_utils).decode("ascii") |
| 134 | script = f""" |
| 135 | import {{ formatDuration }} from {module_url!r}; |
| 136 | if (formatDuration(3_782_000) !== "1h3m2s") throw new Error("hours"); |
| 137 | if (formatDuration(62_000) !== "1m2s") throw new Error("minutes"); |
| 138 | """ |
| 139 | subprocess.run(["node", "--input-type=module", "-e", script], check=True) |
| 140 | |
| 141 | store = (project_root / "plugins" / "_goal" / "webui" / "goal-store.js").read_text() |
| 142 | assert 'import { formatDuration } from "/js/time-utils.js";' in store |
| 143 | assert "return formatDuration(this.elapsedSeconds * 1000);" in store |
| 144 | |
| 145 | |
| 146 | def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str): |
| 147 | created = goal_command.run(_payload(context_id, "/goal Add current goal support")) |
| 148 | assert created["effects"][0]["message"] == "Goal set." |
| 149 | assert created["effects"][2] == {"type": "send_message", "text": "Add current goal support"} |
| 150 | assert goal.get_goal(context_id)["objective"] == "Add current goal support" |
| 151 | |
| 152 | paused = goal_command.run(_payload(context_id, "/goal pause")) |
| 153 | assert paused["effects"][0]["message"] == "Goal paused." |
| 154 | assert goal.get_goal(context_id)["status"] == "paused" |
| 155 | |
| 156 | resumed = goal_command.run(_payload(context_id, "/goal resume")) |
| 157 | assert resumed["effects"][0]["message"] == "Goal resumed." |
| 158 | assert goal.get_goal(context_id)["status"] == "active" |
| 159 | |
| 160 | deleted = goal_command.run(_payload(context_id, "/goal delete")) |
| 161 | assert deleted["effects"][0]["message"] == "Goal deleted." |
| 162 | assert goal.get_goal(context_id) is None |
| 163 | |
| 164 | |
| 165 | def test_goal_auto_fills_prompt(context_id: str): |
| 166 | result = goal_command.run(_payload(context_id, "/goal auto keep this tight")) |
| 167 | |
| 168 | assert "Please create and manage a goal" in result["text"] |
| 169 | assert "User hint: keep this tight" in result["text"] |
| 170 | assert result["effects"] == [] |
| 171 | |
| 172 | |
| 173 | def test_goal_files_stay_under_user_plugin_state(context_id: str): |
| 174 | goal.create_goal(context_id, "Keep state in usr") |
| 175 | goal_path = files.get_abs_path( |
| 176 | files.USER_DIR, |
| 177 | files.PLUGINS_DIR, |
| 178 | goal.PLUGIN_NAME, |
| 179 | goal.GOALS_DIR, |
| 180 | f"{context_id}.json", |
| 181 | ) |
| 182 | |
| 183 | assert files.exists(goal_path) |
| 184 | |
| 185 | |
| 186 | @pytest.mark.asyncio |
| 187 | async def test_goal_api_and_agent_tools(context_id: str): |
| 188 | handler = object.__new__(GoalApi) |
| 189 | created = await handler.process( |
| 190 | { |
| 191 | "action": "set", |
| 192 | "context_id": context_id, |
| 193 | "objective": "Exercise API path", |
| 194 | }, |
| 195 | None, |
| 196 | ) |
| 197 | assert created["ok"] is True |
| 198 | assert created["goal"]["objective"] == "Exercise API path" |
| 199 | |
| 200 | fake_agent = SimpleNamespace(context=SimpleNamespace(id=context_id)) |
| 201 | get_tool = GoalTool(fake_agent, "goal", None, {}, "", None) |
| 202 | get_response = await get_tool.execute() |
| 203 | assert "Exercise API path" in get_response.message |
| 204 | |
| 205 | update_tool = GoalTool(fake_agent, "goal", None, {}, "", None) |
| 206 | update_response = await update_tool.execute(action="update", status="complete") |
| 207 | assert "Status: complete" in update_response.message |
| 208 | |
| 209 | create_tool = GoalTool(fake_agent, "goal", None, {}, "", None) |
| 210 | create_response = await create_tool.execute(action="create", objective="Exercise tool path") |
| 211 | assert "Goal created: Exercise tool path" == create_response.message |
| 212 | assert goal.get_goal(context_id)["created_by"] == "model" |
| 213 | |
| 214 | |
| 215 | @pytest.mark.parametrize("terminal_status", ["blocked", "complete"]) |
| 216 | @pytest.mark.asyncio |
| 217 | async def test_editing_terminal_goal_requests_agent_reactivation( |
| 218 | context_id: str, |
| 219 | terminal_status: str, |
| 220 | ): |
| 221 | goal.create_goal(context_id, "Initial goal") |
| 222 | goal.update_goal(context_id, status=terminal_status) |
| 223 | |
| 224 | response = await object.__new__(GoalApi).process( |
| 225 | { |
| 226 | "action": "update", |
| 227 | "context_id": context_id, |
| 228 | "objective": "Continue with the edited goal", |
| 229 | "status": "active", |
| 230 | }, |
| 231 | None, |
| 232 | ) |
| 233 | |
| 234 | assert response["reactivated"] is True |
| 235 | assert response["goal"]["objective"] == "Continue with the edited goal" |
| 236 | assert response["goal"]["status"] == "active" |
| 237 | |
| 238 | |
| 239 | @pytest.mark.asyncio |
| 240 | async def test_active_goal_keeps_response_tool_running(context_id: str): |
| 241 | goal.create_goal(context_id, "Keep going") |
| 242 | recorded = [] |
| 243 | fake_agent = SimpleNamespace( |
| 244 | context=SimpleNamespace(id=context_id), |
| 245 | hist_add_tool_result=lambda *args, **kwargs: recorded.append((args, kwargs)), |
| 246 | ) |
| 247 | loop_data = SimpleNamespace(params_temporary={}) |
| 248 | tool = ResponseTool( |
| 249 | fake_agent, |
| 250 | "response", |
| 251 | None, |
| 252 | {"text": "Can you decide?"}, |
| 253 | "", |
| 254 | loop_data, |
| 255 | ) |
| 256 | |
| 257 | response = await tool.execute() |
| 258 | assert response.break_loop is False |
| 259 | response.additional["_responses_output_item"] = {"type": "function_call_output"} |
| 260 | await tool.after_execution(response) |
| 261 | assert recorded == [ |
| 262 | ( |
| 263 | ("response", response.message), |
| 264 | {"_responses_output_item": {"type": "function_call_output"}}, |
| 265 | ) |
| 266 | ] |
| 267 | |
| 268 | goal.update_goal(context_id, status="complete") |
| 269 | response = await tool.execute() |
| 270 | assert response.break_loop is True |
| 271 | assert response.message == "Can you decide?" |
| 272 | |
| 273 | |
| 274 | @pytest.mark.asyncio |
| 275 | async def test_native_responses_text_uses_active_goal_response_override( |
| 276 | context_id: str, |
| 277 | monkeypatch, |
| 278 | ): |
| 279 | goal.create_goal(context_id, "Keep going") |
| 280 | recorded = [] |
| 281 | |
| 282 | async def no_op(*args, **kwargs): |
| 283 | return None |
| 284 | |
| 285 | class NoMcpTools: |
| 286 | def get_tool(self, agent, tool_name): |
| 287 | return None |
| 288 | |
| 289 | agent = object.__new__(Agent) |
| 290 | agent.context = SimpleNamespace(id=context_id, log=Log()) |
| 291 | agent.loop_data = LoopData() |
| 292 | agent.data = {} |
| 293 | agent.handle_intervention = no_op |
| 294 | agent._log_response_builtin_items = no_op |
| 295 | agent.hist_add_tool_result = lambda *args, **kwargs: recorded.append((args, kwargs)) |
| 296 | |
| 297 | def get_tool(name, method, args, message, loop_data, **kwargs): |
| 298 | return ResponseTool(agent, name, method, args, message, loop_data) |
| 299 | |
| 300 | agent.get_tool = get_tool |
| 301 | monkeypatch.setattr(extension, "call_extensions_async", no_op) |
| 302 | monkeypatch.setattr(mcp_handler.MCPConfig, "get_instance", lambda: NoMcpTools()) |
| 303 | |
| 304 | result = await Agent.process_llm_result_tools( |
| 305 | agent, |
| 306 | LLMResult(response="Checkpoint for the user."), |
| 307 | ) |
| 308 | |
| 309 | assert result is None |
| 310 | assert recorded[0][0][0] == "response" |
| 311 | assert recorded[0][0][1].startswith("Goal still active.") |
| 312 | assert recorded[0][1] == {} |
| 313 | |
| 314 | goal.update_goal(context_id, status="complete") |
| 315 | result = await Agent.process_llm_result_tools( |
| 316 | agent, |
| 317 | LLMResult(response="Finished."), |
| 318 | ) |
| 319 | |
| 320 | assert result == "Finished." |