mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge remote-tracking branch 'origin/master' into xtr/tool-executor-plugins
# Conflicts: # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # packages/web/tool-web/tests/integration.spec.ts # pnpm-lock.yaml # scripts/check-workspace-constraints.spec.ts # scripts/check-workspace-constraints.ts
This commit is contained in:
commit
38b9a095f3
1000 files changed
+20425
-3924
No files matched your search
@@ -14,6 +14,9 @@
|
||||
/deepseek-harness-python-sdk/customize-plugins:
|
||||
en: 69a6a6745c6e89c1
|
||||
zh: 85e82a4cc31e16e5
|
||||
/deepseek-harness-python-sdk/native-user-questions:
|
||||
en: d9c60c5f43755782
|
||||
zh: 78c82635209ed22d
|
||||
/deepseek-harness-python-sdk/results-and-notifications:
|
||||
en: d4f7366c2d260639
|
||||
zh: 7de99aed2b1bdd0a
|
||||
@@ -87,6 +87,12 @@ with DeepSeekHarness(
|
||||
|
||||
The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides a platform-selected persistent shell, `read_image`, `working_directory`, local execution, JSONL sessions, and automatic compaction. Text file tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. SDK prompt text does not execute `/compact`.
|
||||
|
||||
## Native user questions
|
||||
|
||||
Pass `on_user_question` to `DeepSeekHarnessConfig`, `DeepSeekHarness`, or `HarnessConfig` to answer the built-in `ask_user_question` tool through the native SDK protocol. The callback receives a `UserQuestionRequest` (`request_id`, `session_id`, and the original `questions`) and a `threading.Event` signalling cancellation. Return `UserQuestionAnswer` or a JSON object shaped as `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}`. Each original question id must appear exactly once. The application supplies its UI or simulated-user policy; the SDK does not choose answers.
|
||||
|
||||
Callbacks run on separate threads so the reader can process cancellation and other requests. They may run concurrently and may make further client requests. Release callback-owned work when the cancellation Event is set. Closing the client signals outstanding callbacks and gives their workers a shared 0.5-second join window; callbacks that ignore cancellation may continue, but their late answers cannot reach the runtime or a later process. A callback exception becomes a protocol error, and an absent callback retains existing native answerers or reports `NO_PROVIDER`.
|
||||
|
||||
## Results and notifications
|
||||
|
||||
`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`.
|
||||
|
||||
@@ -87,6 +87,12 @@ with DeepSeekHarness(
|
||||
|
||||
随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它提供按平台选择的持久 shell、`read_image`、`working_directory`、本地执行、JSONL 会话和自动压缩。文本文件工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。SDK 提示词文本不会执行 `/compact`。
|
||||
|
||||
## 原生用户问答
|
||||
|
||||
向 `DeepSeekHarnessConfig`、`DeepSeekHarness` 或 `HarnessConfig` 传入 `on_user_question`,即可通过原生 SDK 协议回答内建的 `ask_user_question` 工具。回调接收 `UserQuestionRequest`(包含 `request_id`、`session_id` 和原始 `questions`)以及表示取消的 `threading.Event`。返回 `UserQuestionAnswer`,或形如 `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}` 的 JSON 对象。每个原问题 id 必须恰好出现一次。应用自行提供 UI 或模拟用户策略;SDK 不选择答案。
|
||||
|
||||
回调在独立线程执行,读取线程仍可处理取消和其他请求。回调可以并发执行,也可以再次调用客户端请求。取消 Event 置位时应释放回调拥有的工作。客户端关闭会通知所有待决回调,并给其工作线程总计 0.5 秒的 join 窗口;忽略取消的回调可能继续执行,但晚到答案不会发送到运行时或后续新进程。回调异常转为协议错误;未提供回调时保留原生回答方,或报告 `NO_PROVIDER`。
|
||||
|
||||
## 结果与通知
|
||||
|
||||
`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
|
||||
from .client import HarnessClient, HarnessConfig
|
||||
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
|
||||
from .errors import SdkProtocolError
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest
|
||||
|
||||
__all__ = [
|
||||
"DeepSeekHarness",
|
||||
@@ -10,6 +10,10 @@ __all__ = [
|
||||
"RunResult",
|
||||
"HarnessClient",
|
||||
"HarnessConfig",
|
||||
"UserQuestionHandler",
|
||||
"UserQuestionRequest",
|
||||
"UserQuestionAnswer",
|
||||
"UserQuestionAnswerItem",
|
||||
"SdkProtocolError",
|
||||
"IncomingRequest",
|
||||
"InitializeResponse",
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .client import HarnessClient, HarnessConfig
|
||||
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
|
||||
from .errors import SdkProtocolError
|
||||
from .models import JsonObject, Notification
|
||||
|
||||
@@ -35,6 +35,7 @@ class DeepSeekHarnessConfig:
|
||||
shutdown_timeout_seconds: float | None = 1.0
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
on_user_question: UserQuestionHandler | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -84,6 +85,7 @@ class DeepSeekHarness:
|
||||
initialize_timeout_seconds=self.config.initialize_timeout_seconds,
|
||||
request_timeout_seconds=self.config.request_timeout_seconds,
|
||||
shutdown_timeout_seconds=self.config.shutdown_timeout_seconds,
|
||||
on_user_question=self.config.on_user_question,
|
||||
),
|
||||
_launch_args=_launch_args,
|
||||
)
|
||||
|
||||
@@ -15,10 +15,11 @@ from typing import Callable, TypeAlias, TypeVar
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .errors import JsonRpcError, SdkProtocolError, TransportClosedError
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
NotificationFilter: TypeAlias = Callable[[Notification], bool]
|
||||
UserQuestionHandler: TypeAlias = Callable[[UserQuestionRequest, threading.Event], UserQuestionAnswer | JsonObject]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -34,6 +35,8 @@ class HarnessConfig:
|
||||
initialize_timeout_seconds: float = 30.0
|
||||
request_timeout_seconds: float | None = None
|
||||
shutdown_timeout_seconds: float | None = 1.0
|
||||
# Callbacks run outside the reader; observe the Event to release work on cancellation.
|
||||
on_user_question: UserQuestionHandler | None = None
|
||||
|
||||
|
||||
class HarnessClient:
|
||||
@@ -60,6 +63,9 @@ class HarnessClient:
|
||||
self._stderr_lines: deque[str] = deque(maxlen=400)
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._stderr_thread: threading.Thread | None = None
|
||||
self._question_tasks: dict[str, tuple[threading.Event, threading.Thread]] = {}
|
||||
self._closing = False
|
||||
self._user_question_handler: UserQuestionHandler | None = None
|
||||
|
||||
def __enter__(self) -> "HarnessClient":
|
||||
self.start()
|
||||
@@ -73,6 +79,9 @@ class HarnessClient:
|
||||
return
|
||||
with self._lock:
|
||||
self._session_parents.clear()
|
||||
self._question_tasks.clear()
|
||||
self._closing = False
|
||||
self._user_question_handler = self.config.on_user_question
|
||||
env = os.environ.copy()
|
||||
if self.config.env:
|
||||
env.update(self.config.env)
|
||||
@@ -93,6 +102,11 @@ class HarnessClient:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the runtime after a bounded opportunity to flush durable state."""
|
||||
with self._lock:
|
||||
self._closing = True
|
||||
question_tasks = list(self._question_tasks.values())
|
||||
for cancelled, _thread in question_tasks:
|
||||
cancelled.set()
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
return
|
||||
@@ -129,6 +143,10 @@ class HarnessClient:
|
||||
self._reader_thread.join(timeout=0.5)
|
||||
if self._stderr_thread and self._stderr_thread.is_alive():
|
||||
self._stderr_thread.join(timeout=0.5)
|
||||
deadline = time.monotonic() + 0.5
|
||||
for _cancelled, thread in question_tasks:
|
||||
if thread is not threading.current_thread():
|
||||
thread.join(timeout=max(0, deadline - time.monotonic()))
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
@@ -148,6 +166,8 @@ class HarnessClient:
|
||||
payload["reasoningEffort"] = reasoning_effort
|
||||
if max_tokens is not None:
|
||||
payload["maxTokens"] = max_tokens
|
||||
if self._user_question_handler is not None:
|
||||
payload["capabilities"] = {"userQuestions": True}
|
||||
try:
|
||||
return self.request(
|
||||
"initialize",
|
||||
@@ -407,7 +427,11 @@ class HarnessClient:
|
||||
method = message.get("method")
|
||||
if isinstance(msg_id, (str, int)) and isinstance(method, str):
|
||||
params = message.get("params")
|
||||
self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {}))
|
||||
request = IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {})
|
||||
if method == "user-question/ask" and self._user_question_handler is not None:
|
||||
self._start_user_question(request)
|
||||
else:
|
||||
self._requests.put(request)
|
||||
return
|
||||
if isinstance(msg_id, (str, int)):
|
||||
with self._lock:
|
||||
@@ -424,6 +448,10 @@ class HarnessClient:
|
||||
params = message.get("params")
|
||||
notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
|
||||
with self._lock:
|
||||
if method == "user-question.cancelled":
|
||||
task = self._question_tasks.get(str(notification.payload.get("requestId")))
|
||||
if task is not None:
|
||||
task[0].set()
|
||||
self._record_session_relationship_locked(notification)
|
||||
subscribers = list(self._notification_subscribers.items())
|
||||
delivered = False
|
||||
@@ -445,6 +473,8 @@ class HarnessClient:
|
||||
|
||||
def _fail_waiters(self, exc: BaseException) -> None:
|
||||
with self._lock:
|
||||
for cancelled, _thread in self._question_tasks.values():
|
||||
cancelled.set()
|
||||
waiters = list(self._responses.values())
|
||||
self._responses.clear()
|
||||
subscribers = list(self._notification_subscribers.values())
|
||||
@@ -456,6 +486,56 @@ class HarnessClient:
|
||||
self._notifications.put(exc)
|
||||
self._requests.put(exc)
|
||||
|
||||
def _start_user_question(self, incoming: IncomingRequest) -> None:
|
||||
"""Keep the reader free to receive cancellation, other responses, and shutdown."""
|
||||
handler = self._user_question_handler
|
||||
proc = self._proc
|
||||
try:
|
||||
request = UserQuestionRequest.model_validate(incoming.payload)
|
||||
if any(not isinstance(item.get("id"), str) or not isinstance(item.get("question"), str) for item in request.questions):
|
||||
raise ValueError("questions require id and question strings")
|
||||
except ValueError:
|
||||
self.respond_error(incoming.id, code=-32602, message="invalid user-question/ask parameters")
|
||||
return
|
||||
cancelled = threading.Event()
|
||||
|
||||
def answer() -> None:
|
||||
try:
|
||||
if cancelled.is_set() or handler is None:
|
||||
return
|
||||
result = handler(request, cancelled)
|
||||
wire_result = result.model_dump(exclude_none=True) if isinstance(result, UserQuestionAnswer) else result
|
||||
with self._lock:
|
||||
if not cancelled.is_set() and self._proc is proc:
|
||||
self.respond(incoming.id, wire_result)
|
||||
except Exception as exc:
|
||||
with self._lock:
|
||||
if not cancelled.is_set() and self._proc is proc:
|
||||
try:
|
||||
self.respond_error(incoming.id, code=-32603, message=str(exc))
|
||||
except TransportClosedError:
|
||||
cancelled.set()
|
||||
finally:
|
||||
with self._lock:
|
||||
current = self._question_tasks.get(request.request_id)
|
||||
if current is not None and current[0] is cancelled:
|
||||
self._question_tasks.pop(request.request_id, None)
|
||||
|
||||
thread = threading.Thread(target=answer, name="dsh-user-question", daemon=True)
|
||||
with self._lock:
|
||||
if self._closing:
|
||||
return
|
||||
if request.request_id in self._question_tasks:
|
||||
self.respond_error(incoming.id, code=-32602, message="duplicate user-question requestId")
|
||||
return
|
||||
self._question_tasks[request.request_id] = (cancelled, thread)
|
||||
try:
|
||||
thread.start()
|
||||
except BaseException:
|
||||
self._question_tasks.pop(request.request_id, None)
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
def _runtime_closed_error(self, reason: str) -> TransportClosedError:
|
||||
diagnostics = self._runtime_diagnostics()
|
||||
return TransportClosedError(f"{reason}\n{diagnostics}" if diagnostics else reason)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"]
|
||||
@@ -30,3 +30,25 @@ class ServerInfo(BaseModel):
|
||||
|
||||
class InitializeResponse(BaseModel):
|
||||
serverInfo: ServerInfo | None = None
|
||||
|
||||
|
||||
class UserQuestionRequest(BaseModel):
|
||||
"""Runtime question batch; questions retain their options, detail, and presentation intent."""
|
||||
|
||||
request_id: str = Field(alias="requestId", min_length=1)
|
||||
session_id: str = Field(alias="sessionId")
|
||||
questions: list[dict[str, PydanticJsonValue]] = Field(min_length=1)
|
||||
|
||||
|
||||
class UserQuestionAnswerItem(BaseModel):
|
||||
"""Answer to one original question id, using selected labels or custom text."""
|
||||
|
||||
id: str
|
||||
selected: list[str]
|
||||
custom: str | None = None
|
||||
|
||||
|
||||
class UserQuestionAnswer(BaseModel):
|
||||
"""One answer per original question; an empty batch is not an implicit approval."""
|
||||
|
||||
answers: list[UserQuestionAnswerItem]
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, UserQuestionRequest
|
||||
from deepseek_harness.errors import TransportClosedError
|
||||
|
||||
|
||||
class Response(BaseModel):
|
||||
answer: dict | None = None
|
||||
error: dict | None = None
|
||||
cancelled: bool = False
|
||||
replies: int = 0
|
||||
capabilities: dict | None = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "questions.py"
|
||||
path.write_text('''
|
||||
import json
|
||||
import sys
|
||||
|
||||
pending = {}
|
||||
replies = 0
|
||||
capabilities = None
|
||||
serial = 0
|
||||
|
||||
def send(frame):
|
||||
print(json.dumps({'jsonrpc': '2.0', **frame}), flush=True)
|
||||
|
||||
for line in sys.stdin:
|
||||
frame = json.loads(line)
|
||||
method = frame.get('method')
|
||||
params = frame.get('params') or {}
|
||||
if method is None:
|
||||
replies += 1
|
||||
original = pending.pop(frame['id'], None)
|
||||
if original is not None:
|
||||
send({'id': original, 'result': {'answer': frame.get('result'), 'error': frame.get('error')}})
|
||||
elif method == 'initialize':
|
||||
capabilities = params.get('capabilities')
|
||||
send({'id': frame['id'], 'result': {'serverInfo': {'name': 'question-peer', 'version': '1'}}})
|
||||
elif method == 'ask':
|
||||
serial += 1
|
||||
wire_id = 'host-' + str(serial)
|
||||
pending[wire_id] = frame['id']
|
||||
send({'id': wire_id, 'method': 'user-question/ask', 'params': {
|
||||
'requestId': params.get('requestId', wire_id), 'sessionId': 'main',
|
||||
'questions': params.get('questions', [{'id': 'format', 'question': 'Which format?', 'detail': 'Plan to review',
|
||||
'options': [{'label': 'CSV'}], 'intent': {'kind': 'plan-review', 'approve': 'CSV'}}])}})
|
||||
elif method == 'cancel':
|
||||
send({'method': 'user-question.cancelled', 'params': {'requestId': params['requestId'], 'sessionId': 'main'}})
|
||||
for original in pending.values():
|
||||
send({'id': original, 'result': {'cancelled': True}})
|
||||
pending.clear()
|
||||
send({'id': frame['id'], 'result': {}})
|
||||
elif method == 'disconnect':
|
||||
break
|
||||
elif method == 'stats':
|
||||
send({'id': frame['id'], 'result': {'replies': replies, 'capabilities': capabilities}})
|
||||
elif method == 'shutdown':
|
||||
send({'id': frame['id'], 'result': {}})
|
||||
break
|
||||
''')
|
||||
return path
|
||||
|
||||
|
||||
def config(callback=None) -> HarnessConfig:
|
||||
return HarnessConfig(on_user_question=callback, request_timeout_seconds=5)
|
||||
|
||||
|
||||
def initialize(client: HarnessClient, root: Path) -> None:
|
||||
client.initialize(cwd=str(root), provider="mock", model="mock")
|
||||
|
||||
|
||||
def test_callback_preserves_question_metadata_and_answers_without_blocking_reader(runtime: Path, tmp_path: Path) -> None:
|
||||
def answer(request: UserQuestionRequest, cancelled: threading.Event):
|
||||
assert request.session_id == "main"
|
||||
assert request.questions[0]["intent"] == {"kind": "plan-review", "approve": "CSV"}
|
||||
assert request.questions[0]["detail"] == "Plan to review"
|
||||
assert not cancelled.is_set()
|
||||
# Reentrant RPC proves the reader keeps processing while the host decides.
|
||||
assert client.request("stats", {}, response_model=Response).capabilities == {"userQuestions": True}
|
||||
return {"answers": [{"id": "format", "selected": ["CSV"]}]}
|
||||
|
||||
with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client:
|
||||
initialize(client, tmp_path)
|
||||
result = client.request("ask", {}, response_model=Response)
|
||||
assert result.answer == {"answers": [{"id": "format", "selected": ["CSV"]}]}
|
||||
assert result.error is None
|
||||
|
||||
|
||||
def test_missing_callback_does_not_advertise_question_support(runtime: Path, tmp_path: Path) -> None:
|
||||
with HarnessClient(config(), _launch_args=(sys.executable, str(runtime))) as client:
|
||||
initialize(client, tmp_path)
|
||||
assert client.request("stats", {}, response_model=Response).capabilities is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["cancel", "disconnect", "close"])
|
||||
def test_cancellation_stops_cooperative_host_work_and_suppresses_late_answer(runtime: Path, tmp_path: Path, action: str) -> None:
|
||||
entered = threading.Event()
|
||||
stopped = threading.Event()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def answer(request: UserQuestionRequest, cancelled: threading.Event):
|
||||
entered.set()
|
||||
assert cancelled.wait(5), "callback did not receive cancellation"
|
||||
stopped.set()
|
||||
return {"answers": [{"id": "format", "selected": ["late"]}]}
|
||||
|
||||
with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client:
|
||||
initialize(client, tmp_path)
|
||||
|
||||
def ask():
|
||||
try:
|
||||
client.request("ask", {"requestId": "pending"}, response_model=Response)
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
caller = threading.Thread(target=ask, daemon=True)
|
||||
caller.start()
|
||||
try:
|
||||
assert entered.wait(5)
|
||||
with client._lock:
|
||||
workers = [thread for _cancelled, thread in client._question_tasks.values()]
|
||||
if action == "close":
|
||||
client.close()
|
||||
else:
|
||||
try:
|
||||
client.request(action, {"requestId": "pending"}, response_model=Response)
|
||||
except TransportClosedError:
|
||||
assert action == "disconnect"
|
||||
assert stopped.wait(5)
|
||||
for worker in workers:
|
||||
worker.join(5)
|
||||
assert not worker.is_alive()
|
||||
caller.join(5)
|
||||
assert not caller.is_alive()
|
||||
if action == "cancel":
|
||||
assert client.request("stats", {}, response_model=Response).replies == 0
|
||||
assert errors == []
|
||||
else:
|
||||
assert len(errors) == 1 and isinstance(errors[0], TransportClosedError)
|
||||
finally:
|
||||
client.close()
|
||||
caller.join(5)
|
||||
|
||||
|
||||
def test_callback_failure_is_returned_to_runtime_and_reader_remains_usable(runtime: Path, tmp_path: Path) -> None:
|
||||
def answer(_request: UserQuestionRequest, _cancelled: threading.Event):
|
||||
raise ValueError("answer service unavailable")
|
||||
|
||||
with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client:
|
||||
initialize(client, tmp_path)
|
||||
result = client.request("ask", {}, response_model=Response)
|
||||
assert result.error == {"code": -32603, "message": "answer service unavailable"}
|
||||
assert client.request("stats", {}, response_model=Response).replies == 1
|
||||
|
||||
|
||||
def test_high_level_config_installs_the_same_native_callback(runtime: Path, tmp_path: Path) -> None:
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), _launch_args=(sys.executable, str(runtime)),
|
||||
on_user_question=lambda _request, _cancelled: {"answers": [{"id": "format", "selected": [], "custom": "JSON"}]},
|
||||
) as harness:
|
||||
result = harness.client.request("ask", {}, response_model=Response)
|
||||
assert result.answer == {"answers": [{"id": "format", "selected": [], "custom": "JSON"}]}
|
||||
|
||||
|
||||
def test_late_callback_cannot_answer_or_remove_a_new_process_question(runtime: Path, tmp_path: Path) -> None:
|
||||
old_entered = threading.Event()
|
||||
release_old = threading.Event()
|
||||
new_entered = threading.Event()
|
||||
stopped = threading.Event()
|
||||
|
||||
def old_answer(_request, cancelled):
|
||||
old_entered.set()
|
||||
assert release_old.wait(5)
|
||||
assert cancelled.is_set()
|
||||
return {"answers": [{"id": "format", "selected": ["old"]}]}
|
||||
|
||||
def new_answer(_request, cancelled):
|
||||
new_entered.set()
|
||||
assert cancelled.wait(5)
|
||||
stopped.set()
|
||||
return {"answers": [{"id": "format", "selected": ["new"]}]}
|
||||
|
||||
failures = []
|
||||
settings = config(old_answer)
|
||||
with HarnessClient(settings, _launch_args=(sys.executable, str(runtime))) as client:
|
||||
initialize(client, tmp_path)
|
||||
|
||||
def ask():
|
||||
try:
|
||||
client.request("ask", {"requestId": "reused"}, response_model=Response)
|
||||
except TransportClosedError as error:
|
||||
failures.append(error)
|
||||
|
||||
old_caller = threading.Thread(target=ask, daemon=True)
|
||||
new_caller = threading.Thread(target=ask, daemon=True)
|
||||
old_caller.start()
|
||||
try:
|
||||
assert old_entered.wait(5)
|
||||
with client._lock:
|
||||
old_worker = client._question_tasks["reused"][1]
|
||||
client.close()
|
||||
old_caller.join(5)
|
||||
assert not old_caller.is_alive()
|
||||
settings.on_user_question = new_answer
|
||||
client.start()
|
||||
initialize(client, tmp_path)
|
||||
new_caller.start()
|
||||
assert new_entered.wait(5)
|
||||
release_old.set()
|
||||
old_worker.join(5)
|
||||
assert not old_worker.is_alive()
|
||||
assert client.request("stats", {}, response_model=Response).replies == 0
|
||||
client.request("cancel", {"requestId": "reused"}, response_model=Response)
|
||||
assert stopped.wait(5)
|
||||
new_caller.join(5)
|
||||
assert not new_caller.is_alive()
|
||||
assert len(failures) == 1
|
||||
finally:
|
||||
release_old.set()
|
||||
client.close()
|
||||
old_caller.join(5)
|
||||
if new_caller.ident is not None:
|
||||
new_caller.join(5)
|
||||
Reference in new issue
Block a user