Reuse the existing run input for command images and ordinary queue/steer delivery. Align Unicode command separators, bound goal observation, preserve plan input encoding, and handle explicitly expected Windows cancellation disconnects in runtime tests.
203 lines
11 KiB
Python
203 lines
11 KiB
Python
"""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 '<goal_complete>' 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]))
|