"""Native goal rounds and state changes through the Python SDK profile.""" from __future__ import annotations import json import time import threading import pytest from pathlib import Path from deepseek_harness import CommandExecution, Session from deepseek_harness.client import NotificationSubscription from test_commands_runtime import ( _Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _WaitResult, _assert_request_permission, _command_pair, _of_type, _permission_facts, _read_events, _request_texts, _runtime, _text_blocks, _wait_activity, ) def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution: result = runtime.call(session.run, text) assert isinstance(result, CommandExecution) assert result.result.kind == kind, result.result.text return result def _until(subscription: NotificationSubscription, event_type: str, operation: str | None = None) -> dict: deadline = time.monotonic() + _WAIT_SECONDS while True: notification = subscription.next(timeout_seconds=max(0, deadline - time.monotonic())) if notification.method != "session.event": continue event = notification.payload["event"] if event["type"] == event_type and (operation is None or event["data"].get("operation") == operation): return event["data"] def _complete_goal(request: dict) -> tuple[str, dict]: results = [block for message in request["messages"] for block in message["content"] if block["type"] == "tool_result"] current = json.loads("\n".join(_text_blocks(results[-1]["content"]))) goal = current["goal"] assert goal["phase"] == "active" and current["activation"] == "armed" return "update_goal", {"goal_id": goal["id"], "revision": goal["revision"], "action": "complete"} def _completion_steps(final: _Completion | None = None) -> list[_Completion]: return [_Completion("", tool_call=("get_goal", {})), _Completion("", tool_call=_complete_goal), final if final is not None else _Completion("GOAL_DONE")] def test_goal_reads_and_invalid_controls_do_not_start_model_work(tmp_path: Path) -> None: with _runtime(tmp_path, []) as runtime: session = runtime.harness.start_session(_SESSION_ID) executions = [_command(runtime, session, "/目标")] for line in ["/goal edit", "/goal edit Missing goal", "/goal pause", "/goal resume"]: executions.append(_command(runtime, session, line, kind="error")) executions.append(_command(runtime, session, "/goal clear")) assert runtime.model.captured() == [] events = _read_events(runtime.logs) assert not _of_type(events, "goal/change") and not _of_type(events, "turn/start") for execution in executions: _command_pair(events, execution, "goal") def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: final = _Completion("GOAL_DONE", blocked=True) with _runtime(tmp_path, [_Completion("PROGRESS"), *_completion_steps(final)], initial_permission="workspace-write", max_goal_rounds=3) as runtime: session = runtime.harness.start_session(_SESSION_ID) with runtime.harness.client.subscribe_session_notifications(session.id) as events: execution = _command(runtime, session, "/目标 Verify the small change.") completed = _until(events, "goal/change", "complete") assert completed["goal"]["phase"] == "complete" and completed["roundsStarted"] == 2 assert final.entered.wait(_WAIT_SECONDS) client = runtime.harness.client sent = threading.Event() replied = threading.Event() wait_ids = set() write = client._write_message handle = client._handle_message def record_send(message: dict) -> None: is_wait = message.get("method") == "session/wait" if is_wait: wait_ids.add(message["id"]) write(message) if is_wait: sent.set() def record_response(message: object) -> None: if isinstance(message, dict) and "method" not in message and message.get("id") in wait_ids: replied.set() handle(message) monkeypatch.setattr(client, "_write_message", record_send) monkeypatch.setattr(client, "_handle_message", record_response) waiting = runtime.workers.submit(client.request, "session/wait", {"sessionId": session.id}, response_model=_WaitResult) assert sent.wait(_WAIT_SECONDS) # Observe the reader directly so caller scheduling cannot hide an early response. runtime.call(client.get_working_directory, session.id) assert not replied.is_set() and not final.release.is_set() final.release.set() assert waiting.result(timeout=_WAIT_SECONDS).model_dump() == {} assert _until(events, "turn/end")["turn"] == 2 requests = runtime.model.captured() assert len(requests) == 4 assert 'Objective: "Verify the small change."\nRound: 1/3' in "\n".join(_request_texts(requests[0])) assert 'Round: 2/3' in "\n".join(_request_texts(requests[1])) assert '' in "\n".join(_request_texts(requests[-1])) for request in requests: _assert_request_permission(request, "workspace-write", "ask") assert runtime.harness.config.env["DSH_PERMISSION_MODE"] == "workspace-write" events = _read_events(runtime.logs) _command_pair(events, execution, "goal") assert [e["data"]["turn"] for e in _of_type(events, "turn/start")] == [1, 2] assert [e["data"] for e in _of_type(events, "step/start")] == [ {"turn": 1, "step": 1}, {"turn": 2, "step": 1}, {"turn": 2, "step": 2}, {"turn": 2, "step": 3}, ] assert len(_permission_facts(events)) == 3 def test_pause_aborts_then_edit_and_resume_use_revised_goal_without_changing_other_sessions(tmp_path: Path) -> None: first = _Completion("CANCELLED_RESPONSE", blocked=True, allow_disconnect=True) with _runtime(tmp_path, [first, *_completion_steps(), _Completion("RAW_GOAL_TEXT")]) as runtime: session = runtime.harness.start_session(_SESSION_ID) with runtime.harness.client.subscribe_session_notifications(session.id) as events: _command(runtime, session, "/goal Original objective") assert first.entered.wait(_WAIT_SECONDS) _command(runtime, session, "/goal Duplicate objective", kind="error") _command(runtime, session, "/goal edit", kind="error") _command(runtime, session, "/goal resume", kind="error") _command(runtime, session, "/goal PAUSE") assert _until(events, "turn/end")["reason"]["kind"] == "aborted" assert not first.release.is_set() _command(runtime, session, "/goal edit Revised objective") assert len(runtime.model.captured()) == 1 other = runtime.harness.start_session("goal-other") assert "No goal is currently set" in _command(runtime, other, "/goal").result.text _command(runtime, session, "/goal resume") _until(events, "goal/change", "complete") _wait_activity(runtime, session) assert 'Objective: "Revised objective"' in "\n".join(_request_texts(runtime.model.captured()[1])) _command(runtime, session, "/goal clear") _command(runtime, session, "/goal clear") runtime.call(runtime.harness.client.session_prompt, session.id, [{"type": "text", "text": "/goal Plain text objective"}]) _wait_activity(runtime, session) assert "/goal Plain text objective" in _request_texts(runtime.model.captured()[-1]) changes = _of_type(_read_events(runtime.logs), "goal/change") assert [e["data"]["operation"] for e in changes] == ["create", "pause", "edit", "resume", "complete", "clear"] assert not _of_type(_read_events(runtime.logs, session_id="goal-other"), "goal/change") def test_clear_keeps_current_request_and_stops_further_goal_rounds(tmp_path: Path) -> None: first = _Completion("CURRENT_ROUND_FINISHED", blocked=True) with _runtime(tmp_path, [first]) as runtime: session = runtime.harness.start_session(_SESSION_ID) with runtime.harness.client.subscribe_session_notifications(session.id) as events: _command(runtime, session, "/goal Work until cleared") assert first.entered.wait(_WAIT_SECONDS) _command(runtime, session, "/goal clear") first.release.set() _wait_activity(runtime, session) assert _until(events, "turn/end")["reason"]["kind"] == "completed" assert len(runtime.model.captured()) == 1 events = _read_events(runtime.logs) assert len(_of_type(events, "assistant/message")) == 1 assert [e["data"]["operation"] for e in _of_type(events, "goal/change")] == ["create", "clear"] def test_round_limit_blocks_and_rejects_resume_without_another_request(tmp_path: Path) -> None: with _runtime(tmp_path, [_Completion("ONE"), _Completion("TWO")], max_goal_rounds=2) as runtime: session = runtime.harness.start_session(_SESSION_ID) with runtime.harness.client.subscribe_session_notifications(session.id) as events: _command(runtime, session, "/goal An unfinished objective") blocked = _until(events, "goal/change", "block") assert blocked["goal"]["blockedReason"]["code"] == "round-limit" assert blocked["roundsStarted"] == 2 _command(runtime, session, "/goal resume", kind="error") _wait_activity(runtime, session) assert len(runtime.model.captured()) == 2 def test_restored_active_goal_waits_for_explicit_resume(tmp_path: Path) -> None: first = _Completion("INTERRUPTED", blocked=True, allow_disconnect=True) with _runtime(tmp_path, [first], max_goal_rounds=3) as runtime: session = runtime.harness.start_session(_SESSION_ID) runtime.call(runtime.harness.client.session_prompt, session.id, [{"type": "text", "text": "Start an ordinary task"}]) assert first.entered.wait(_WAIT_SECONDS) _command(runtime, session, "/goal Persisted objective") runtime.harness.close() with _runtime(tmp_path, _completion_steps()) as runtime: session = runtime.call(runtime.harness.resume_session, _SESSION_ID) shown = _command(runtime, session, "/goal") assert "Status: active" in shown.result.text and "Activation: disarmed" in shown.result.text assert runtime.model.captured() == [] with runtime.harness.client.subscribe_session_notifications(session.id) as events: _command(runtime, session, "/goal resume") _until(events, "goal/change", "complete") _wait_activity(runtime, session) assert 'Objective: "Persisted objective"\nRound: 1/3' in "\n".join(_request_texts(runtime.model.captured()[0]))