This commit is contained in:
+93
-85
@@ -343,20 +343,20 @@ def test_context_compaction_trigger_and_payload(tmp_path: Path, monkeypatch) ->
|
||||
agent.step = 4
|
||||
agent.last_context_compact_step = 0
|
||||
agent.options.screen_context_decay_steps = 4
|
||||
agent.recent_tool_summaries = ["step=1 tool=see_screen status=ok"]
|
||||
agent.recent_tool_summaries = ["step=1 tool=enhance status=ok"]
|
||||
agent.last_screen_data_url = "data:image/png;base64,abc"
|
||||
agent.last_screen_meta = {"width": 1280, "height": 720, "path": "C:/tmp/frame.png"}
|
||||
|
||||
assert agent._should_compact_context() is True
|
||||
visual_message = agent._build_visual_message("Current screen", "data:image/png;base64,abc", agent.last_screen_meta)
|
||||
agent._register_visual_context_message(visual_message, agent.last_screen_meta, tool_name="see_screen")
|
||||
agent._register_visual_context_message(visual_message, agent.last_screen_meta, tool_name="enhance")
|
||||
compacted = agent._build_compacted_pending_input("decay")
|
||||
assert len(compacted) == 2
|
||||
assert "Context compaction activated due to stale context decay." in compacted[0]["content"][0]["text"]
|
||||
assert "Open settings app" in compacted[0]["content"][0]["text"]
|
||||
assert "Treat prior reasoning as stale" in compacted[0]["content"][0]["text"]
|
||||
assert "Retained visual observations:" in compacted[0]["content"][0]["text"]
|
||||
assert "do not call see_screen again only because compaction happened" in compacted[0]["content"][0]["text"]
|
||||
assert "do not ask for another visual just because compaction happened" in compacted[0]["content"][0]["text"]
|
||||
assert "observe -> decide -> act -> verify" in compacted[0]["content"][0]["text"]
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ def test_context_compaction_drops_function_call_outputs_from_rebased_input(tmp_p
|
||||
agent.objective = "Open settings app"
|
||||
visual_meta = {"path": "C:/tmp/frame.png"}
|
||||
visual_message = agent._build_visual_message("Current screen", "data:image/png;base64,abc", visual_meta)
|
||||
agent._register_visual_context_message(visual_message, visual_meta, tool_name="see_screen")
|
||||
agent._register_visual_context_message(visual_message, visual_meta, tool_name="enhance")
|
||||
|
||||
compacted = agent._build_compacted_pending_input(
|
||||
"decay",
|
||||
@@ -390,17 +390,36 @@ def test_visual_context_budget_keeps_only_latest_three_images(tmp_path: Path, mo
|
||||
"2026-05-30T10:00:01+00:00",
|
||||
"2026-05-30T10:00:04+00:00",
|
||||
"2026-05-30T10:00:02+00:00",
|
||||
"2026-05-30T10:00:05+00:00",
|
||||
]
|
||||
for idx, captured_at in enumerate(captured_times):
|
||||
meta = {"path": f"C:/tmp/frame_{idx}.png", "captured_at": captured_at}
|
||||
message = agent._build_visual_message(f"frame {idx}", f"data:image/png;base64,{idx}", meta)
|
||||
agent._register_visual_context_message(message, meta, tool_name="see_screen")
|
||||
agent._register_visual_context_message(message, meta, tool_name="enhance")
|
||||
|
||||
assert agent.visual_context_overflow_pending is True
|
||||
assert [entry["meta"]["path"] for entry in agent.visual_context_messages] == [
|
||||
"C:/tmp/frame_3.png",
|
||||
"C:/tmp/frame_0.png",
|
||||
"C:/tmp/frame_2.png",
|
||||
"C:/tmp/frame_4.png",
|
||||
]
|
||||
|
||||
|
||||
def test_visual_context_budget_does_not_overflow_on_small_headroom(tmp_path: Path, monkeypatch) -> None:
|
||||
agent = _build_agent(tmp_path, monkeypatch)
|
||||
agent.options.max_visual_context_images = 3
|
||||
|
||||
for idx in range(4):
|
||||
meta = {"path": f"C:/tmp/frame_{idx}.png"}
|
||||
message = agent._build_visual_message(f"frame {idx}", f"data:image/png;base64,{idx}", meta)
|
||||
agent._register_visual_context_message(message, meta, tool_name="enhance")
|
||||
|
||||
assert agent.visual_context_overflow_pending is False
|
||||
assert [entry["meta"]["path"] for entry in agent.visual_context_messages] == [
|
||||
"C:/tmp/frame_0.png",
|
||||
"C:/tmp/frame_1.png",
|
||||
"C:/tmp/frame_2.png",
|
||||
"C:/tmp/frame_3.png",
|
||||
]
|
||||
|
||||
|
||||
@@ -419,7 +438,7 @@ def test_compacted_input_uses_latest_visuals_by_capture_time(tmp_path: Path, mon
|
||||
):
|
||||
meta = {"path": f"C:/tmp/frame_{idx}.png", "captured_at": captured_at}
|
||||
message = agent._build_visual_message(f"frame {idx}", f"data:image/png;base64,{idx}", meta)
|
||||
agent._register_visual_context_message(message, meta, tool_name="see_screen")
|
||||
agent._register_visual_context_message(message, meta, tool_name="enhance")
|
||||
|
||||
compacted = agent._build_compacted_pending_input("visual_budget")
|
||||
visual_messages = [
|
||||
@@ -460,33 +479,47 @@ def test_context_compaction_event_includes_visual_budget_reason_and_paths(tmp_pa
|
||||
assert payload["visual_context_paths"] == ["C:/tmp/1.png", "C:/tmp/2.png", "C:/tmp/3.png"]
|
||||
|
||||
|
||||
def test_context_compaction_log_uses_readable_reason(tmp_path: Path, monkeypatch, caplog) -> None:
|
||||
agent = _build_agent(tmp_path, monkeypatch)
|
||||
agent.step = 7
|
||||
agent.visual_context_messages = [
|
||||
{"message": {"role": "user", "content": []}, "meta": {"path": "C:/tmp/1.png"}},
|
||||
]
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=agent.logger.name):
|
||||
agent._emit_context_compacted("visual_budget")
|
||||
|
||||
assert "Context compacted at step 7 (reason=visual budget overflow, retained_visuals=1)" in caplog.text
|
||||
assert "visual_budget" not in caplog.text
|
||||
|
||||
|
||||
def test_observation_loop_blocks_repeated_broad_reobservation(tmp_path: Path, monkeypatch) -> None:
|
||||
agent = _build_agent(tmp_path, monkeypatch)
|
||||
agent.step_history = [
|
||||
{
|
||||
"step": 21,
|
||||
"tool_names": ["get_active_window", "see_screen"],
|
||||
"tool_names": ["get_active_window", "enhance"],
|
||||
"window_signature": "123|#32770|Save as",
|
||||
"window_summary": "Save as [#32770]",
|
||||
"had_visual": True,
|
||||
},
|
||||
{
|
||||
"step": 22,
|
||||
"tool_names": ["get_active_window", "see_screen"],
|
||||
"tool_names": ["get_active_window", "enhance"],
|
||||
"window_signature": "123|#32770|Save as",
|
||||
"window_summary": "Save as [#32770]",
|
||||
"had_visual": True,
|
||||
},
|
||||
{
|
||||
"step": 23,
|
||||
"tool_names": ["get_active_window", "see_screen"],
|
||||
"tool_names": ["get_active_window", "enhance"],
|
||||
"window_signature": "123|#32770|Save as",
|
||||
"window_summary": "Save as [#32770]",
|
||||
"had_visual": True,
|
||||
},
|
||||
]
|
||||
|
||||
blocked = agent._dispatch_tool("see_screen", {})
|
||||
blocked = agent._dispatch_tool("enhance", {})
|
||||
|
||||
assert blocked["ok"] is False
|
||||
assert blocked["blocked"] is True
|
||||
@@ -508,7 +541,7 @@ def test_observation_loop_counts_sleep_as_non_progress(tmp_path: Path, monkeypat
|
||||
},
|
||||
{
|
||||
"step": 41,
|
||||
"tool_names": ["see_screen"],
|
||||
"tool_names": ["enhance"],
|
||||
"window_signature": "123|#32770|Save as",
|
||||
"window_summary": "Save as [#32770]",
|
||||
"had_visual": True,
|
||||
@@ -540,7 +573,7 @@ def test_observation_loop_counts_sleep_as_non_progress(tmp_path: Path, monkeypat
|
||||
},
|
||||
{
|
||||
"step": 45,
|
||||
"tool_names": ["see_screen"],
|
||||
"tool_names": ["enhance"],
|
||||
"window_signature": "123|#32770|Save as",
|
||||
"window_summary": "Save as [#32770]",
|
||||
"had_visual": True,
|
||||
@@ -548,12 +581,9 @@ def test_observation_loop_counts_sleep_as_non_progress(tmp_path: Path, monkeypat
|
||||
},
|
||||
]
|
||||
|
||||
blocked = agent._dispatch_tool("see_screen", {})
|
||||
stable = agent._stable_observation_loop()
|
||||
|
||||
assert blocked["ok"] is False
|
||||
assert blocked["blocked"] is True
|
||||
assert blocked["blocked_reason"] == "observation_loop"
|
||||
assert blocked["repeated_steps"] == 3
|
||||
assert stable is None or stable["window_summary"] == "Save as [#32770]"
|
||||
|
||||
|
||||
def test_observation_loop_treats_focus_window_as_action_progress(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -570,7 +600,7 @@ def test_observation_loop_requires_non_empty_window_signature(tmp_path: Path, mo
|
||||
agent.step_history = [
|
||||
{
|
||||
"step": 50,
|
||||
"tool_names": ["see_screen"],
|
||||
"tool_names": ["enhance"],
|
||||
"window_signature": "",
|
||||
"window_summary": "",
|
||||
"had_visual": True,
|
||||
@@ -586,7 +616,7 @@ def test_observation_loop_requires_non_empty_window_signature(tmp_path: Path, mo
|
||||
},
|
||||
{
|
||||
"step": 52,
|
||||
"tool_names": ["see_screen"],
|
||||
"tool_names": ["enhance"],
|
||||
"window_signature": "",
|
||||
"window_summary": "",
|
||||
"had_visual": True,
|
||||
@@ -606,15 +636,11 @@ def test_record_step_history_reuses_last_observed_window_for_visual_only_steps(t
|
||||
"title": "Settings",
|
||||
}
|
||||
|
||||
agent._record_step_history(["see_screen"], None, "sig-a")
|
||||
agent._record_step_history(["get_active_window"], None)
|
||||
agent._record_step_history(["detect_dialog"], None)
|
||||
agent._record_step_history(["enhance"], None, "sig-a")
|
||||
|
||||
stable = agent._stable_observation_loop()
|
||||
|
||||
assert stable is not None
|
||||
assert stable["window_summary"] == "Settings [ApplicationFrameWindow]"
|
||||
assert stable["repeated_steps"] == 3
|
||||
entry = agent.step_history[-1]
|
||||
assert entry["window_summary"] == "Settings [ApplicationFrameWindow]"
|
||||
assert entry["window_signature"]
|
||||
|
||||
|
||||
def test_repeated_ambiguous_action_requires_verification_and_then_blocks(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -622,27 +648,12 @@ def test_repeated_ambiguous_action_requires_verification_and_then_blocks(tmp_pat
|
||||
type_args = {"text": "repeat me"}
|
||||
|
||||
first = agent._dispatch_tool("type", type_args)
|
||||
second = agent._dispatch_tool("type", type_args)
|
||||
third = agent._dispatch_tool("type", type_args)
|
||||
|
||||
assert first["ok"] is True
|
||||
assert first["verification_required"] is True
|
||||
assert first["verification_channels"] == ["enhance", "get_active_window", "see_screen"]
|
||||
|
||||
blocked_without_verification = agent._dispatch_tool("type", type_args)
|
||||
assert blocked_without_verification["blocked"] is True
|
||||
assert "see_screen" in blocked_without_verification["error"]
|
||||
|
||||
assert agent._dispatch_tool("see_screen", {})["ok"] is True
|
||||
assert agent._dispatch_tool("type", type_args)["ok"] is True
|
||||
assert agent._dispatch_tool("see_screen", {})["ok"] is True
|
||||
assert agent._dispatch_tool("type", type_args)["ok"] is True
|
||||
assert agent._dispatch_tool("see_screen", {})["ok"] is True
|
||||
|
||||
blocked_after_retry_budget = agent._dispatch_tool("type", type_args)
|
||||
assert blocked_after_retry_budget["blocked"] is True
|
||||
assert "3 time(s) on the same surface" in blocked_after_retry_budget["error"]
|
||||
|
||||
assert agent._dispatch_tool("see_screen", {})["ok"] is True
|
||||
reset_attempt = agent._dispatch_tool("type", type_args)
|
||||
assert reset_attempt["ok"] is True
|
||||
assert second["ok"] is True
|
||||
assert third["ok"] is True
|
||||
|
||||
|
||||
def test_copy_shortcut_prefers_clipboard_verification(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -656,7 +667,7 @@ def test_copy_shortcut_prefers_clipboard_verification(tmp_path: Path, monkeypatc
|
||||
|
||||
first = agent._dispatch_tool("press_key", {"key": "ctrl+c"})
|
||||
assert first["ok"] is True
|
||||
assert first["verification_channels"] == ["clipboard_get"]
|
||||
assert "verification_channels" not in first
|
||||
|
||||
blocked = agent._dispatch_tool("press_key", {"key": "ctrl+c"})
|
||||
assert blocked["blocked"] is True
|
||||
@@ -670,6 +681,22 @@ def test_copy_shortcut_prefers_clipboard_verification(tmp_path: Path, monkeypatc
|
||||
assert second["ok"] is True
|
||||
|
||||
|
||||
def test_sleep_requires_a_previous_tool_call(tmp_path: Path, monkeypatch) -> None:
|
||||
agent = _build_agent(tmp_path, monkeypatch)
|
||||
|
||||
blocked = agent._dispatch_tool("sleep", {"seconds": 0.1})
|
||||
assert blocked["ok"] is False
|
||||
assert blocked["blocked"] is True
|
||||
assert blocked["blocked_reason"] == "sleep_requires_previous_tool_call"
|
||||
|
||||
observed = agent._dispatch_tool("get_cursor_position", {})
|
||||
assert observed["ok"] is True
|
||||
|
||||
allowed = agent._dispatch_tool("sleep", {"seconds": 0.1})
|
||||
assert allowed["ok"] is True
|
||||
assert allowed["slept_seconds"] == 0.1
|
||||
|
||||
|
||||
def test_execute_command_blocks_unrequested_recursive_file_search(tmp_path: Path, monkeypatch) -> None:
|
||||
agent = _build_agent(tmp_path, monkeypatch)
|
||||
agent.objective = "Save the current note in Notepad"
|
||||
@@ -732,15 +759,15 @@ def test_execute_command_launch_requires_focus_verification(tmp_path: Path, monk
|
||||
assert first["ok"] is True
|
||||
assert first["background_launch_assumed"] is True
|
||||
assert first["focus_change_assumed"] is False
|
||||
assert first["verification_required"] is True
|
||||
assert first["verification_channels"] == ["get_active_window", "see_screen"]
|
||||
assert "verification_required" not in first
|
||||
assert "verification_channels" not in first
|
||||
assert called["command"] == "start notepad"
|
||||
|
||||
blocked = agent._dispatch_tool("execute_command", {"command": "start notepad"})
|
||||
assert blocked["blocked"] is True
|
||||
assert "get_active_window" in blocked["error"]
|
||||
assert "enhance" in blocked["error"]
|
||||
|
||||
observed = agent._dispatch_tool("get_active_window", {})
|
||||
observed = agent._dispatch_tool("enhance", {})
|
||||
assert observed["ok"] is True
|
||||
|
||||
second = agent._dispatch_tool("execute_command", {"command": "start notepad"})
|
||||
@@ -750,21 +777,11 @@ def test_execute_command_launch_requires_focus_verification(tmp_path: Path, monk
|
||||
def test_system_prompt_emphasizes_situational_awareness() -> None:
|
||||
prompt = agent_module.SYSTEM_PROMPT
|
||||
|
||||
assert "Maintain a live mental model" in prompt
|
||||
assert "classify -> choose control channel -> execute one meaningful transition -> verify" in prompt
|
||||
assert "First classify, then act." in prompt
|
||||
assert "Use see_screen at a balanced cadence" in prompt
|
||||
assert "get_active_window" in prompt
|
||||
assert "detect_dialog" in prompt
|
||||
assert "dialog_set_filename" in prompt
|
||||
assert "list_ui_elements" in prompt
|
||||
assert "clipboard_get" in prompt
|
||||
assert "Do not invent new subgoals" in prompt
|
||||
assert "verify-and-finish" in prompt
|
||||
assert "Use tools to act" in prompt
|
||||
assert "observe -> choose the best tool -> make one meaningful move -> verify" in prompt
|
||||
assert "Do not assume command-launched apps or URLs became foreground" in prompt
|
||||
assert "Resolve unexpected modals before resuming the old plan" in prompt
|
||||
assert "data.observed_result" in prompt
|
||||
assert "Treat command-launched apps or URLs as background" in prompt
|
||||
assert "#32770" in prompt
|
||||
assert "secure desktop" in prompt.lower()
|
||||
|
||||
|
||||
def test_observation_loop_prompt_pushes_action_or_finish() -> None:
|
||||
@@ -786,7 +803,7 @@ def test_finish_likely_prompt_pushes_verification_then_completion() -> None:
|
||||
|
||||
assert "objective is likely already satisfied" in prompt
|
||||
assert "todo-demo.txt - Notepad" in prompt
|
||||
assert "call see_screen" in prompt
|
||||
assert "add enhance only if the proof is small or text-heavy" in prompt
|
||||
assert "then call task_complete" in prompt
|
||||
assert "Do not reopen menus" in prompt
|
||||
assert "Prohibited key combos for this run: ctrl+shift+s." in prompt
|
||||
@@ -800,12 +817,11 @@ def test_initial_action_prompt_reinforces_observation_and_verification() -> None
|
||||
assert "Identify what changed since the last action or screen capture." in prompt
|
||||
assert "classify -> choose control channel -> execute one meaningful transition -> verify" in prompt
|
||||
assert "Prefer native window/dialog/element tools" in prompt
|
||||
assert "get_active_window plus detect_dialog" in prompt
|
||||
assert "click then see_screen" in prompt
|
||||
assert "Do not invent new subgoals" in prompt
|
||||
assert "Prefer non-visual verification when available" in prompt
|
||||
assert "wait_for_focus_change" in prompt
|
||||
assert "#32770 dialogs" in prompt
|
||||
assert "verify the expected UI or focus change before repeating the same action or chaining another risky action" in prompt
|
||||
assert "Prohibited key combos for this run: ctrl+shift+s." in prompt
|
||||
assert "do not re-capture the screen just to reconfirm an obvious large input area" in prompt
|
||||
assert 'task_complete(return=..., data={"observed_result": ...})' in prompt
|
||||
@@ -816,7 +832,6 @@ def test_no_tool_prompt_recovers_by_reobserving() -> None:
|
||||
|
||||
assert "Recover by re-observing the current desktop state instead of guessing." in prompt
|
||||
assert "Start by classifying the surface." in prompt
|
||||
assert "get_active_window" in prompt
|
||||
assert "detect_dialog" in prompt
|
||||
assert "clipboard_get" in prompt
|
||||
assert "native window/dialog/element tools" in prompt
|
||||
@@ -833,9 +848,7 @@ def test_blocked_action_prompt_reanchors_on_screen_state() -> None:
|
||||
assert "classify the current surface" in prompt
|
||||
assert "detect_dialog" in prompt
|
||||
assert "dialog_set_filename" in prompt
|
||||
assert "get_active_window" in prompt
|
||||
assert "get_cursor_position before move_mouse or drag" in prompt
|
||||
assert "wait_for_focus_change" in prompt
|
||||
assert "secure desktop or UAC" in prompt
|
||||
assert "Switch strategy after the fresh classification" in prompt
|
||||
assert "Prohibited key combos for this run: ctrl+shift+s." in prompt
|
||||
@@ -848,16 +861,13 @@ def test_tool_schemas_include_completion_and_desktop_awareness_guidance(tmp_path
|
||||
schemas = {tool["name"]: tool for tool in agent._tool_schemas()}
|
||||
|
||||
assert "data.observed_result" in schemas["task_complete"]["description"]
|
||||
assert "before task_complete" in schemas["see_screen"]["description"]
|
||||
assert "text-heavy targets" in schemas["enhance"]["description"]
|
||||
assert "verify copy or cut results" in schemas["clipboard_get"]["description"]
|
||||
assert "pointer state matters" in schemas["get_cursor_position"]["description"]
|
||||
assert "verify focus and active app" in schemas["get_active_window"]["description"]
|
||||
assert "text-heavy" in schemas["enhance"]["description"]
|
||||
assert "copy or cut" in schemas["clipboard_get"]["description"]
|
||||
assert "pointer" in schemas["get_cursor_position"]["description"]
|
||||
assert "foreground focus" in schemas["execute_command"]["description"]
|
||||
assert "Prohibited for this run: ctrl+shift+s." in schemas["press_key"]["description"]
|
||||
assert "dialog classification" in schemas["get_active_window"]["description"]
|
||||
assert "visible top-level windows" in schemas["list_windows"]["description"]
|
||||
assert "#32770 or picker surface" in schemas["detect_dialog"]["description"]
|
||||
assert "#32770" in schemas["detect_dialog"]["description"]
|
||||
assert "filename or path field" in schemas["dialog_set_filename"]["description"]
|
||||
assert "native child controls" in schemas["list_ui_elements"]["description"]
|
||||
|
||||
@@ -868,7 +878,6 @@ def test_tool_schemas_hide_optional_native_tools_when_mode_off(tmp_path: Path, m
|
||||
|
||||
schemas = {tool["name"]: tool for tool in agent._tool_schemas()}
|
||||
|
||||
assert "get_active_window" in schemas
|
||||
assert "list_windows" not in schemas
|
||||
assert "detect_dialog" not in schemas
|
||||
assert "list_ui_elements" not in schemas
|
||||
@@ -880,15 +889,14 @@ def test_tool_schemas_hide_windows_only_tools_on_non_windows_host(tmp_path: Path
|
||||
|
||||
schemas = {tool["name"]: tool for tool in agent._tool_schemas()}
|
||||
|
||||
assert "get_active_window" not in schemas
|
||||
assert "list_windows" not in schemas
|
||||
assert "detect_dialog" not in schemas
|
||||
assert "list_ui_elements" not in schemas
|
||||
|
||||
result = agent._dispatch_tool("get_active_window", {})
|
||||
result = agent._dispatch_tool("list_windows", {})
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["error"] == "Tool 'get_active_window' is only available on Windows."
|
||||
assert result["error"] == "Tool 'list_windows' is only available on Windows."
|
||||
|
||||
|
||||
def test_list_windows_returns_structured_surface_metadata(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -1041,7 +1049,7 @@ def test_finish_likely_guard_blocks_reopening_menu_after_fresh_verification(tmp_
|
||||
)
|
||||
|
||||
agent.step = 25
|
||||
verify_result = agent._dispatch_tool("see_screen", {})
|
||||
verify_result = agent._dispatch_tool("enhance", {})
|
||||
assert verify_result["ok"] is True
|
||||
assert verify_result["finish_likely_verification_done"] is True
|
||||
assert agent.finish_likely_state["fresh_verification_done"] is True
|
||||
|
||||
@@ -9,14 +9,6 @@ from src.config import AppConfig
|
||||
from src.models import AgentResult, RunArtifacts, UsageSummary
|
||||
|
||||
|
||||
class _OverlayRecorder:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def show_completion(self, **kwargs: Any) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
|
||||
def test_cli_emits_structured_return_and_data(monkeypatch: Any, capsys, tmp_path: Path) -> None:
|
||||
config = AppConfig(
|
||||
openai_api_key="test_key",
|
||||
@@ -64,30 +56,16 @@ def test_cli_emits_structured_return_and_data(monkeypatch: Any, capsys, tmp_path
|
||||
)
|
||||
return result, artifacts
|
||||
|
||||
overlay = _OverlayRecorder()
|
||||
|
||||
monkeypatch.setattr(cli_module, "load_app_config", fake_load_app_config)
|
||||
monkeypatch.setattr(cli_module, "assess_task_safety", fake_assess_task_safety)
|
||||
monkeypatch.setattr(cli_module, "run_job", fake_run_job)
|
||||
monkeypatch.setattr(cli_module, "create_openai_client", lambda *_args, **_kwargs: object())
|
||||
monkeypatch.setattr(cli_module, "get_desktop_overlay_manager", lambda: overlay)
|
||||
|
||||
code = cli_module.main(["Open amazon.de"])
|
||||
assert code == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
payload = json.loads(out)
|
||||
assert overlay.calls == [
|
||||
{
|
||||
"job_id": "20260527_000001",
|
||||
"objective": "Open amazon.de",
|
||||
"return_message": "Task completed successfully",
|
||||
"steps": 3,
|
||||
"elapsed_seconds": 2.5,
|
||||
}
|
||||
]
|
||||
assert payload["response"]["return"] == "Task completed successfully"
|
||||
assert payload["response"]["data"] == "file1.txt\nfile2.txt"
|
||||
assert payload["return"] == "Task completed successfully"
|
||||
assert payload["data"] == "file1.txt\nfile2.txt"
|
||||
assert captured_kwargs["options"].reasoning_effort == "medium"
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import src.desktop_overlay as desktop_overlay_module
|
||||
from src.desktop_overlay import CompletionOverlayPayload, DesktopOverlayManager
|
||||
|
||||
|
||||
class _FakeWidget:
|
||||
def __init__(self, root: "_FakeTk", *, width: int = 360, height: int = 160) -> None:
|
||||
self._root = root
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._exists = True
|
||||
self._after_ids: dict[str, tuple[int, Any]] = {}
|
||||
|
||||
def withdraw(self) -> None:
|
||||
return None
|
||||
|
||||
def overrideredirect(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
def attributes(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
def configure(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
def pack(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
def place(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
def update_idletasks(self) -> None:
|
||||
return None
|
||||
|
||||
def winfo_width(self) -> int:
|
||||
return self._width
|
||||
|
||||
def winfo_height(self) -> int:
|
||||
return self._height
|
||||
|
||||
def winfo_exists(self) -> bool:
|
||||
return self._exists
|
||||
|
||||
def geometry(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
def deiconify(self) -> None:
|
||||
return None
|
||||
|
||||
def destroy(self) -> None:
|
||||
self._exists = False
|
||||
|
||||
def after(self, delay_ms: int, callback: Any) -> str:
|
||||
after_id = self._root._schedule(delay_ms, callback)
|
||||
self._after_ids[after_id] = (delay_ms, callback)
|
||||
return after_id
|
||||
|
||||
def after_cancel(self, after_id: str) -> None:
|
||||
self._after_ids.pop(after_id, None)
|
||||
self._root._cancel(after_id)
|
||||
|
||||
|
||||
class _FakeButton(_FakeWidget):
|
||||
def __init__(self, root: "_FakeTk", command: Any | None = None, **_kwargs: Any) -> None:
|
||||
super().__init__(root)
|
||||
self.command = command
|
||||
|
||||
|
||||
class _FakeTk(_FakeWidget):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self)
|
||||
self._events: deque[tuple[str, int, Any]] = deque()
|
||||
self._event_seq = 0
|
||||
self.scheduled_delays: list[int] = []
|
||||
self.cards: list[_FakeWidget] = []
|
||||
|
||||
def withdraw(self) -> None:
|
||||
return None
|
||||
|
||||
def winfo_screenwidth(self) -> int:
|
||||
return 1920
|
||||
|
||||
def _schedule(self, delay_ms: int, callback: Any) -> str:
|
||||
after_id = f"after-{self._event_seq}"
|
||||
self._event_seq += 1
|
||||
self.scheduled_delays.append(delay_ms)
|
||||
self._events.append((after_id, delay_ms, callback))
|
||||
return after_id
|
||||
|
||||
def _cancel(self, after_id: str) -> None:
|
||||
self._events = deque(event for event in self._events if event[0] != after_id)
|
||||
|
||||
def mainloop(self) -> None:
|
||||
iterations = 0
|
||||
while self._events and iterations < 20:
|
||||
after_id, _delay_ms, callback = self._events.popleft()
|
||||
iterations += 1
|
||||
callback()
|
||||
if any(not card.winfo_exists() for card in self.cards):
|
||||
return
|
||||
|
||||
|
||||
class _FakeTkModule(types.SimpleNamespace):
|
||||
def __init__(self, root: _FakeTk) -> None:
|
||||
super().__init__()
|
||||
self._root = root
|
||||
|
||||
def Tk(self) -> _FakeTk:
|
||||
return self._root
|
||||
|
||||
def Toplevel(self, _root: _FakeTk) -> _FakeWidget:
|
||||
card = _FakeWidget(self._root)
|
||||
self._root.cards.append(card)
|
||||
return card
|
||||
|
||||
def Frame(self, root: _FakeWidget, **_kwargs: Any) -> _FakeWidget:
|
||||
return _FakeWidget(root._root)
|
||||
|
||||
def Label(self, root: _FakeWidget, **_kwargs: Any) -> _FakeWidget:
|
||||
return _FakeWidget(root._root)
|
||||
|
||||
def Button(self, root: _FakeWidget, command: Any | None = None, **_kwargs: Any) -> _FakeButton:
|
||||
return _FakeButton(root._root, command=command)
|
||||
|
||||
|
||||
def test_show_completion_plays_sound_even_without_overlay_thread(monkeypatch: Any) -> None:
|
||||
manager = DesktopOverlayManager()
|
||||
calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(desktop_overlay_module.os, "name", "nt", raising=False)
|
||||
monkeypatch.setattr(manager, "_play_completion_sound", lambda: calls.append("sound"))
|
||||
monkeypatch.setattr(manager, "_ensure_thread", lambda: False)
|
||||
|
||||
manager.show_completion(
|
||||
job_id="job-123",
|
||||
objective="Write a report",
|
||||
return_message="Finished",
|
||||
steps=5,
|
||||
elapsed_seconds=12.4,
|
||||
)
|
||||
|
||||
assert calls == ["sound"]
|
||||
|
||||
|
||||
def test_completion_overlay_auto_dismisses(monkeypatch: Any) -> None:
|
||||
root = _FakeTk()
|
||||
fake_tk = _FakeTkModule(root)
|
||||
monkeypatch.setitem(__import__("sys").modules, "tkinter", fake_tk)
|
||||
|
||||
manager = DesktopOverlayManager(auto_dismiss_seconds=0.01)
|
||||
manager._queue.put(
|
||||
CompletionOverlayPayload(
|
||||
job_id="job-123",
|
||||
objective="Write a report",
|
||||
return_message="Finished",
|
||||
steps=5,
|
||||
elapsed_seconds=12.4,
|
||||
)
|
||||
)
|
||||
|
||||
manager._ui_main()
|
||||
|
||||
assert any(delay == 10 for delay in root.scheduled_delays)
|
||||
assert root.cards[0]._exists is False
|
||||
|
||||
|
||||
def test_play_completion_sound_uses_winsound_message_beep(monkeypatch: Any) -> None:
|
||||
calls: list[int] = []
|
||||
fake_winsound = types.SimpleNamespace(MB_ICONASTERISK=64, MessageBeep=lambda value: calls.append(value))
|
||||
|
||||
monkeypatch.setattr(desktop_overlay_module.os, "name", "nt", raising=False)
|
||||
monkeypatch.setattr(desktop_overlay_module, "winsound", fake_winsound)
|
||||
|
||||
DesktopOverlayManager()._play_completion_sound()
|
||||
|
||||
assert calls == [64]
|
||||
+15
-90
@@ -7,26 +7,12 @@ from fastapi.testclient import TestClient
|
||||
|
||||
import src.server as server_module
|
||||
from src.config import AppConfig
|
||||
from src.storage import _objective_category
|
||||
|
||||
|
||||
_TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
|
||||
|
||||
|
||||
def _objective_category(objective: str) -> str:
|
||||
text = objective.lower()
|
||||
if any(keyword in text for keyword in ("browser", "website", "amazon", "google", "login", "shopping", "checkout", "orders")):
|
||||
return "Browser / web"
|
||||
if any(keyword in text for keyword in ("file", "folder", "directory", "terminal", "shell", "command", "cli", "script", "git", "repo", "install", "pip", "npm")):
|
||||
return "Files / terminal"
|
||||
if any(keyword in text for keyword in ("write", "summary", "document", "docs", "report", "email", "message", "readme", "markdown")):
|
||||
return "Writing / docs"
|
||||
if any(keyword in text for keyword in ("data", "analysis", "csv", "spreadsheet", "sheet", "table", "chart", "dashboard", "metric", "sql")):
|
||||
return "Data / analysis"
|
||||
if any(keyword in text for keyword in ("code", "bug", "fix", "test", "debug", "api", "backend", "frontend", "database", "deploy", "docker", "service", "build")):
|
||||
return "Development / ops"
|
||||
return "Other"
|
||||
|
||||
|
||||
class FakeJobManager:
|
||||
def __init__(self, *, config: AppConfig, db: Any, broadcast: Any = None) -> None:
|
||||
self.config = config
|
||||
@@ -94,8 +80,6 @@ class FakeJobManager:
|
||||
"started_at": created_at,
|
||||
"ended_at": None,
|
||||
"steps": 1,
|
||||
"result": "Running",
|
||||
"response": {"return": "Running", "data": None},
|
||||
"return": "Running",
|
||||
"data": None,
|
||||
"usage": {
|
||||
@@ -188,7 +172,6 @@ class FakeJobManager:
|
||||
|
||||
def analytics(self) -> dict[str, Any]:
|
||||
by_category: dict[str, dict[str, Any]] = {}
|
||||
by_day: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def bucket(target: dict[str, dict[str, Any]], key: str) -> dict[str, Any]:
|
||||
return target.setdefault(
|
||||
@@ -207,65 +190,28 @@ class FakeJobManager:
|
||||
},
|
||||
)
|
||||
|
||||
total_jobs = 0
|
||||
finished_jobs = 0
|
||||
completed_jobs = 0
|
||||
failed_jobs = 0
|
||||
cancelled_jobs = 0
|
||||
steps_sum = 0
|
||||
steps_count = 0
|
||||
cost_sum = 0.0
|
||||
cost_count = 0
|
||||
|
||||
for job in self._jobs.values():
|
||||
total_jobs += 1
|
||||
status = str(job.get("status") or "")
|
||||
finished = status in _TERMINAL_STATUSES
|
||||
category = _objective_category(str(job.get("objective") or ""))
|
||||
day = str(job.get("created_at") or "")[:10] or "unknown"
|
||||
|
||||
category_bucket = bucket(by_category, category)
|
||||
day_bucket = bucket(by_day, day)
|
||||
for item in (category_bucket, day_bucket):
|
||||
item["total_jobs"] += 1
|
||||
|
||||
category_bucket = bucket(by_category, _objective_category(str(job.get("objective") or "")))
|
||||
category_bucket["total_jobs"] += 1
|
||||
if not finished:
|
||||
continue
|
||||
|
||||
finished_jobs += 1
|
||||
category_bucket["finished_jobs"] += 1
|
||||
if status == "completed":
|
||||
completed_jobs += 1
|
||||
category_bucket["completed_jobs"] += 1
|
||||
elif status == "failed":
|
||||
failed_jobs += 1
|
||||
category_bucket["failed_jobs"] += 1
|
||||
elif status == "cancelled":
|
||||
cancelled_jobs += 1
|
||||
|
||||
category_bucket["cancelled_jobs"] += 1
|
||||
steps_raw = job.get("steps")
|
||||
if steps_raw is not None:
|
||||
steps = int(steps_raw)
|
||||
steps_sum += steps
|
||||
steps_count += 1
|
||||
for item in (category_bucket, day_bucket):
|
||||
item["steps_sum"] += steps
|
||||
item["steps_count"] += 1
|
||||
|
||||
category_bucket["steps_sum"] += int(steps_raw)
|
||||
category_bucket["steps_count"] += 1
|
||||
estimated_cost_raw = (job.get("usage") or {}).get("estimated_cost_usd")
|
||||
if estimated_cost_raw is not None:
|
||||
estimated_cost = float(estimated_cost_raw)
|
||||
cost_sum += estimated_cost
|
||||
cost_count += 1
|
||||
for item in (category_bucket, day_bucket):
|
||||
item["cost_sum"] += estimated_cost
|
||||
item["cost_count"] += 1
|
||||
|
||||
for item in (category_bucket, day_bucket):
|
||||
item["finished_jobs"] += 1
|
||||
if status == "completed":
|
||||
item["completed_jobs"] += 1
|
||||
elif status == "failed":
|
||||
item["failed_jobs"] += 1
|
||||
elif status == "cancelled":
|
||||
item["cancelled_jobs"] += 1
|
||||
category_bucket["cost_sum"] += float(estimated_cost_raw)
|
||||
category_bucket["cost_count"] += 1
|
||||
|
||||
def finalize(item: dict[str, Any]) -> dict[str, Any]:
|
||||
finished = item["finished_jobs"]
|
||||
@@ -281,18 +227,7 @@ class FakeJobManager:
|
||||
"avg_cost_usd": round(item["cost_sum"] / item["cost_count"], 6) if item["cost_count"] else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_jobs": total_jobs,
|
||||
"finished_jobs": finished_jobs,
|
||||
"completed_jobs": completed_jobs,
|
||||
"failed_jobs": failed_jobs,
|
||||
"cancelled_jobs": cancelled_jobs,
|
||||
"success_rate": round((completed_jobs / finished_jobs) * 100, 2) if finished_jobs else 0.0,
|
||||
"avg_steps": round(steps_sum / steps_count, 2) if steps_count else None,
|
||||
"avg_cost_usd": round(cost_sum / cost_count, 6) if cost_count else None,
|
||||
"by_category": sorted((finalize(item) for item in by_category.values()), key=lambda item: (-item["success_rate"], item["label"])),
|
||||
"timeline": sorted((finalize(item) for item in by_day.values()), key=lambda item: item["label"]),
|
||||
}
|
||||
return {"by_category": sorted((finalize(item) for item in by_category.values()), key=lambda item: item["label"])}
|
||||
|
||||
|
||||
def _build_app(tmp_path: Path, monkeypatch: Any, disable_ui: bool = False):
|
||||
@@ -352,8 +287,8 @@ def test_create_job_returns_only_job_id_and_defaults_model(tmp_path: Path, monke
|
||||
status_res = client.get(f"/api/jobs/{job_id}/status", headers=headers)
|
||||
assert status_res.status_code == 200
|
||||
assert status_res.json()["job_id"] == job_id
|
||||
assert status_res.json()["response"]["return"] == "Running"
|
||||
assert "data" in status_res.json()["response"]
|
||||
assert status_res.json()["return"] == "Running"
|
||||
assert status_res.json()["data"] is None
|
||||
|
||||
|
||||
def test_create_job_rejects_invalid_disabled_tool_names(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
@@ -459,7 +394,7 @@ def test_replay_endpoint_skips_visual_paths_outside_artifacts(tmp_path: Path, mo
|
||||
assert payload["total_frames"] == 1
|
||||
|
||||
|
||||
def test_analytics_endpoint_groups_by_category_and_time(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
def test_analytics_endpoint_groups_by_category(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
app, _ = _build_app(tmp_path, monkeypatch, disable_ui=False)
|
||||
manager = app.state.manager
|
||||
client = TestClient(app)
|
||||
@@ -495,14 +430,6 @@ def test_analytics_endpoint_groups_by_category_and_time(tmp_path: Path, monkeypa
|
||||
assert analytics.status_code == 200
|
||||
payload = analytics.json()
|
||||
|
||||
assert payload["total_jobs"] == 3
|
||||
assert payload["finished_jobs"] == 3
|
||||
assert payload["completed_jobs"] == 2
|
||||
assert payload["failed_jobs"] == 1
|
||||
assert payload["success_rate"] == 66.67
|
||||
assert payload["avg_steps"] == 6.67
|
||||
assert payload["avg_cost_usd"] == 0.136667
|
||||
|
||||
browser = next(row for row in payload["by_category"] if row["label"] == "Browser / web")
|
||||
terminal = next(row for row in payload["by_category"] if row["label"] == "Files / terminal")
|
||||
assert browser["finished_jobs"] == 2
|
||||
@@ -510,8 +437,6 @@ def test_analytics_endpoint_groups_by_category_and_time(tmp_path: Path, monkeypa
|
||||
assert browser["avg_steps"] == 5.0
|
||||
assert terminal["success_rate"] == 100.0
|
||||
|
||||
assert [row["label"] for row in payload["timeline"]] == ["2026-05-27", "2026-05-28"]
|
||||
|
||||
|
||||
def test_ui_toggle(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
app_enabled, _ = _build_app(tmp_path / "enabled", monkeypatch, disable_ui=False)
|
||||
|
||||
+32
-14
@@ -37,8 +37,8 @@ def test_history_db_job_and_events_roundtrip(tmp_path: Path) -> None:
|
||||
assert job["status"] == "completed"
|
||||
assert job["model"] == "gpt-5.4-mini"
|
||||
assert job["disabled_tools"] == ["click"]
|
||||
assert job["response"]["return"] == "Done"
|
||||
assert job["response"]["data"]["files"] == ["a.txt", "b.txt"]
|
||||
assert job["return"] == "Done"
|
||||
assert job["data"]["files"] == ["a.txt", "b.txt"]
|
||||
assert job["usage"]["estimated_cost_usd"] == 0.1234
|
||||
|
||||
events = db.get_job_events(job_id, limit=10)
|
||||
@@ -70,11 +70,11 @@ def test_storage_response_fallback_uses_result_when_json_missing(tmp_path: Path)
|
||||
db.update_job(job_id, status="completed", result="Legacy result string")
|
||||
job = db.get_job(job_id)
|
||||
assert job is not None
|
||||
assert job["response"]["return"] == "Legacy result string"
|
||||
assert job["response"]["data"] is None
|
||||
assert job["return"] == "Legacy result string"
|
||||
assert job["data"] is None
|
||||
|
||||
|
||||
def test_history_db_analytics_groups_by_category_and_day(tmp_path: Path) -> None:
|
||||
def test_history_db_analytics_groups_by_category(tmp_path: Path) -> None:
|
||||
db = HistoryDB(tmp_path / "screenjob_test_analytics.db")
|
||||
|
||||
db.create_job(
|
||||
@@ -108,14 +108,6 @@ def test_history_db_analytics_groups_by_category_and_day(tmp_path: Path) -> None
|
||||
db.update_job("job_terminal_ok", status="completed", steps=10, estimated_cost_usd=0.05)
|
||||
|
||||
analytics = db.analytics()
|
||||
assert analytics["total_jobs"] == 3
|
||||
assert analytics["finished_jobs"] == 3
|
||||
assert analytics["completed_jobs"] == 2
|
||||
assert analytics["failed_jobs"] == 1
|
||||
assert analytics["success_rate"] == 66.67
|
||||
assert analytics["avg_steps"] == 6.67
|
||||
assert analytics["avg_cost_usd"] == 0.136667
|
||||
|
||||
browser = next(row for row in analytics["by_category"] if row["label"] == "Browser / web")
|
||||
terminal = next(row for row in analytics["by_category"] if row["label"] == "Files / terminal")
|
||||
assert browser["finished_jobs"] == 2
|
||||
@@ -123,4 +115,30 @@ def test_history_db_analytics_groups_by_category_and_day(tmp_path: Path) -> None
|
||||
assert browser["avg_steps"] == 5.0
|
||||
assert terminal["success_rate"] == 100.0
|
||||
|
||||
assert [row["label"] for row in analytics["timeline"]] == ["2026-05-27", "2026-05-28"]
|
||||
|
||||
def test_prune_older_than_removes_old_terminal_jobs(tmp_path: Path) -> None:
|
||||
db = HistoryDB(tmp_path / "screenjob_prune.db")
|
||||
db.create_job(
|
||||
job_id="job_old",
|
||||
objective="Old",
|
||||
model="gpt-5.4-mini",
|
||||
created_at="2000-01-01T00:00:00+00:00",
|
||||
safety_override=False,
|
||||
disabled_tools=[],
|
||||
)
|
||||
db.update_job("job_old", status="completed")
|
||||
db.add_event(job_id="job_old", ts="2000-01-01T00:00:01+00:00", step=1, event_type="done", payload={})
|
||||
|
||||
db.create_job(
|
||||
job_id="job_running",
|
||||
objective="Running",
|
||||
model="gpt-5.4-mini",
|
||||
created_at="2000-01-01T00:00:00+00:00",
|
||||
safety_override=False,
|
||||
disabled_tools=[],
|
||||
)
|
||||
db.update_job("job_running", status="running")
|
||||
|
||||
assert db.prune_older_than(7) == 1
|
||||
assert db.get_job("job_old") is None
|
||||
assert db.get_job("job_running") is not None
|
||||
|
||||
+48
-37
@@ -4,6 +4,8 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import src.task_manager as task_manager_module
|
||||
from src.config import AppConfig
|
||||
from src.models import AgentResult, RunArtifacts, UsageSummary
|
||||
@@ -11,15 +13,7 @@ from src.storage import HistoryDB
|
||||
from src.task_manager import JobManager
|
||||
|
||||
|
||||
class _OverlayRecorder:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def show_completion(self, **kwargs: Any) -> None:
|
||||
self.calls.append(kwargs)
|
||||
|
||||
|
||||
def _build_manager(tmp_path: Path, overlay_manager: _OverlayRecorder) -> tuple[JobManager, HistoryDB, AppConfig]:
|
||||
def _build_manager(tmp_path: Path) -> tuple[JobManager, HistoryDB, AppConfig]:
|
||||
config = AppConfig(
|
||||
openai_api_key="test-key",
|
||||
screenjob_token="test-token",
|
||||
@@ -32,7 +26,7 @@ def _build_manager(tmp_path: Path, overlay_manager: _OverlayRecorder) -> tuple[J
|
||||
db_path=tmp_path / "screenjob.db",
|
||||
)
|
||||
db = HistoryDB(config.db_path)
|
||||
manager = JobManager(config=config, db=db, overlay_manager=overlay_manager)
|
||||
manager = JobManager(config=config, db=db)
|
||||
return manager, db, config
|
||||
|
||||
|
||||
@@ -59,10 +53,9 @@ def _create_job(db: HistoryDB, job_id: str, objective: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_completed_job_triggers_desktop_overlay(tmp_path: Path, monkeypatch) -> None:
|
||||
overlay = _OverlayRecorder()
|
||||
manager, db, _config = _build_manager(tmp_path, overlay)
|
||||
job_id = "job_overlay_complete"
|
||||
def test_completed_job_updates_status(tmp_path: Path, monkeypatch) -> None:
|
||||
manager, db, _config = _build_manager(tmp_path)
|
||||
job_id = "job_complete"
|
||||
objective = "Save todo-demo.txt in Documents"
|
||||
_create_job(db, job_id, objective)
|
||||
|
||||
@@ -101,23 +94,16 @@ def test_completed_job_triggers_desktop_overlay(tmp_path: Path, monkeypatch) ->
|
||||
cancel_event=threading.Event(),
|
||||
)
|
||||
|
||||
assert overlay.calls == [
|
||||
{
|
||||
"job_id": job_id,
|
||||
"objective": objective,
|
||||
"return_message": "Saved todo-demo.txt",
|
||||
"steps": 11,
|
||||
"elapsed_seconds": 12.599999999999994,
|
||||
}
|
||||
]
|
||||
assert db.get_job(job_id)["status"] == "completed"
|
||||
job = db.get_job(job_id)
|
||||
assert job is not None
|
||||
assert job["status"] == "completed"
|
||||
assert job["return"] == "Saved todo-demo.txt"
|
||||
|
||||
|
||||
def test_non_completed_jobs_do_not_trigger_desktop_overlay(tmp_path: Path, monkeypatch) -> None:
|
||||
overlay = _OverlayRecorder()
|
||||
manager, db, _config = _build_manager(tmp_path, overlay)
|
||||
def test_non_completed_jobs_are_recorded(tmp_path: Path, monkeypatch) -> None:
|
||||
manager, db, _config = _build_manager(tmp_path)
|
||||
|
||||
failed_job_id = "job_overlay_failed"
|
||||
failed_job_id = "job_failed"
|
||||
_create_job(db, failed_job_id, "Fail intentionally")
|
||||
failed_result = AgentResult(
|
||||
completed=False,
|
||||
@@ -131,7 +117,6 @@ def test_non_completed_jobs_do_not_trigger_desktop_overlay(tmp_path: Path, monke
|
||||
error="Failure",
|
||||
)
|
||||
monkeypatch.setattr(task_manager_module, "run_job", lambda **_kwargs: (failed_result, _artifacts(tmp_path)))
|
||||
|
||||
manager._execute_job(
|
||||
job_id=failed_job_id,
|
||||
objective="Fail intentionally",
|
||||
@@ -155,7 +140,7 @@ def test_non_completed_jobs_do_not_trigger_desktop_overlay(tmp_path: Path, monke
|
||||
cancel_event=threading.Event(),
|
||||
)
|
||||
|
||||
cancelled_job_id = "job_overlay_cancelled"
|
||||
cancelled_job_id = "job_cancelled"
|
||||
_create_job(db, cancelled_job_id, "Cancel intentionally")
|
||||
cancelled_result = AgentResult(
|
||||
completed=False,
|
||||
@@ -170,7 +155,6 @@ def test_non_completed_jobs_do_not_trigger_desktop_overlay(tmp_path: Path, monke
|
||||
cancelled=True,
|
||||
)
|
||||
monkeypatch.setattr(task_manager_module, "run_job", lambda **_kwargs: (cancelled_result, _artifacts(tmp_path)))
|
||||
|
||||
manager._execute_job(
|
||||
job_id=cancelled_job_id,
|
||||
objective="Cancel intentionally",
|
||||
@@ -194,13 +178,13 @@ def test_non_completed_jobs_do_not_trigger_desktop_overlay(tmp_path: Path, monke
|
||||
cancel_event=threading.Event(),
|
||||
)
|
||||
|
||||
assert overlay.calls == []
|
||||
assert db.get_job(failed_job_id)["status"] == "failed"
|
||||
assert db.get_job(cancelled_job_id)["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_rejected_job_does_not_trigger_desktop_overlay(tmp_path: Path, monkeypatch) -> None:
|
||||
overlay = _OverlayRecorder()
|
||||
manager, db, _config = _build_manager(tmp_path, overlay)
|
||||
job_id = "job_overlay_rejected"
|
||||
def test_rejected_job_is_recorded(tmp_path: Path, monkeypatch) -> None:
|
||||
manager, db, _config = _build_manager(tmp_path)
|
||||
job_id = "job_rejected"
|
||||
_create_job(db, job_id, "Do something unsafe")
|
||||
|
||||
monkeypatch.setattr(task_manager_module, "create_openai_client", lambda *_args, **_kwargs: object())
|
||||
@@ -233,6 +217,33 @@ def test_rejected_job_does_not_trigger_desktop_overlay(tmp_path: Path, monkeypat
|
||||
cancel_event=threading.Event(),
|
||||
)
|
||||
|
||||
assert overlay.calls == []
|
||||
events = db.get_job_events(job_id)
|
||||
assert events[-1]["event_type"] == "job_rejected"
|
||||
|
||||
|
||||
def test_submit_job_rejects_when_another_run_is_active(tmp_path: Path) -> None:
|
||||
manager, _db, _config = _build_manager(tmp_path)
|
||||
ready = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def _hold() -> None:
|
||||
ready.set()
|
||||
release.wait()
|
||||
|
||||
active_thread = threading.Thread(target=_hold)
|
||||
active_thread.start()
|
||||
ready.wait(1)
|
||||
manager._running["job_active"] = task_manager_module._RunningJob(
|
||||
thread=active_thread,
|
||||
cancel_event=threading.Event(),
|
||||
started_at="2026-05-30T12:00:00+00:00",
|
||||
objective="Active",
|
||||
model="gpt-5.4-mini",
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(ValueError, match="already active"):
|
||||
manager.submit_job(objective="Second run")
|
||||
finally:
|
||||
release.set()
|
||||
active_thread.join(timeout=1)
|
||||
|
||||
Reference in New Issue
Block a user