main
py 320 lines 10.6 KB
Raw
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."