fix(sdk): tighten slash command observation and regression coverage
This commit is contained in:
1 parent
9c3971618a
commit
f7379ef366
29 files changed
+346
-199
No files matched your search
@@ -12,8 +12,8 @@
|
||||
en: c4c30913fe2642ef
|
||||
zh: 0ed42bca37fba60b
|
||||
/deepseek-harness-python-sdk/query-and-response:
|
||||
en: c9e73f3b9934032f
|
||||
zh: bf44fb5d471dbe75
|
||||
en: 7cc5feefefd05142
|
||||
zh: ea346bf354ec266d
|
||||
/deepseek-harness-python-sdk/client-approvals:
|
||||
en: fc69be1beb8abff1
|
||||
zh: 91b56781931a6f68
|
||||
|
||||
@@ -61,13 +61,13 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
<a id="query-and-response"></a>
|
||||
## Query and response
|
||||
|
||||
Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The full SDK intercepts bare `/compact`, `/permission <preset>` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/model` and other input remain ordinary prompts. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command.
|
||||
Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The runtime recognizes the [supported command forms and Desktop aliases](../../packages/sdk/server/README.md#command-input); unrecognized input remains ordinary text. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command.
|
||||
|
||||
`receive_response()` claims the next query in send order. It first waits for the routing response, then yields `Notification` objects followed by one terminal value: `RunResult` for the prompt's inbox-receipt-to-idle interval, or `CommandExecution` after the command settles. Notifications can accumulate before receiving begins. Separate receivers can observe their queries concurrently, and another `query()` can be sent while a receiver waits. Close an unfinished iterator to release only its local observation; this does not cancel or roll back runtime work. Resources are released before yielding the terminal value.
|
||||
|
||||
`CommandExecution` contains `command_id` and a `result` discriminated by `kind`. Success may include `text` and `source_event_seq`; an error includes `text`. Handler errors return `kind="error"`; routing, protocol and transport failures raise during receiving. Sending failures raise from `query()`. `request_timeout_seconds` bounds waiting for the routing response, not later prompt activity; an already-arrived response remains receivable after that deadline.
|
||||
|
||||
The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` strictly accepts `/compact`, `/permission <preset>`, `/plan [off|message]`, or `/goal [objective|clear|edit objective|pause|resume]` and rejects bare `/permission`. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
|
||||
The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` executes supported command names without prompt fallback; it requires a non-blank permission preset. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
|
||||
|
||||
`/permission <preset>` delegates to the native permission handler. An unknown preset returns `CommandExecution.result.kind="error"` without changing permission state. A successful change belongs to that Session: later queries and restoration retain it, other Sessions retain their own state, and new Sessions use configured defaults. `DeepSeekHarnessConfig` remains startup input, not a shared live state mirror; the command does not rewrite it or saved user defaults. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions).
|
||||
|
||||
|
||||
@@ -61,13 +61,13 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
<a id="query-and-response"></a>
|
||||
## 查询与接收
|
||||
|
||||
调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。完整 SDK 接管裸 `/compact`、带非空 preset 的 `/permission <preset>`、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 及其余输入仍是普通提示词。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。
|
||||
调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。运行时识别[支持的命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input);未识别的输入保持普通文本。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。
|
||||
|
||||
`receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。
|
||||
|
||||
`CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。
|
||||
|
||||
完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 严格接受 `/compact`、`/permission <preset>`、`/plan [off|message]` 或 `/goal [objective|clear|edit objective|pause|resume]`,并拒绝裸 `/permission`。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 执行支持的命令名,不回退为提示词;permission preset 必须非空。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
|
||||
`/permission <preset>` 委托给原生权限 handler。未知 preset 返回 `CommandExecution.result.kind="error"`,不改变权限状态。成功的变更属于该 Session:后续 query 和恢复保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。`DeepSeekHarnessConfig` 仍是启动输入,不是共享的实时状态镜像;命令不重写它或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外模型 step。另见[权限示例](examples/README.zh.md#change-session-permissions)。
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 3471642d8444d0e9
|
||||
zh: 7831f28295a6e68a
|
||||
/python-sdk-example/review-a-plan:
|
||||
en: bd0da5138a033eba
|
||||
zh: f291c3ae8967e936
|
||||
en: 46758c022608af0f
|
||||
zh: 09b5a59176c11176
|
||||
/python-sdk-example/run-a-goal:
|
||||
en: ae73e9fd9d402047
|
||||
zh: f9694bc6a6e12929
|
||||
|
||||
@@ -68,7 +68,7 @@ python python/sdk/examples/plan.py \
|
||||
"Plan the requested change and present it for review."
|
||||
```
|
||||
|
||||
The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait`; its question callback is independent of the protocol reader.
|
||||
The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait` and rejects truncated or failed root turns; its question callback is independent of the protocol reader.
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## Run a goal
|
||||
|
||||
@@ -68,7 +68,7 @@ python python/sdk/examples/plan.py \
|
||||
"Plan the requested change and present it for review."
|
||||
```
|
||||
|
||||
原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,问答回调独立于协议读取线程。
|
||||
原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,并报告根轮次截断或失败;问答回调独立于协议读取线程。
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## 运行目标
|
||||
|
||||
@@ -40,14 +40,19 @@ def review(request: UserQuestionRequest, cancelled: threading.Event) -> dict:
|
||||
return {"cancelled": True}
|
||||
|
||||
|
||||
def display(notification: Notification) -> None:
|
||||
"""Print completed assistant text while the Session runs."""
|
||||
def display(notification: Notification, session_id: str) -> None:
|
||||
"""Print assistant text and report an unsuccessful root Session turn."""
|
||||
if notification.method == "session.event":
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict) and event.get("type") == "assistant/message":
|
||||
text = final_response([event])
|
||||
if text:
|
||||
print(text)
|
||||
elif (isinstance(event, dict) and event.get("type") == "turn/end"
|
||||
and notification.payload.get("sessionId") == session_id):
|
||||
reason = event["data"]["reason"]
|
||||
if reason["kind"] != "completed":
|
||||
raise RuntimeError(f"Agent turn ended: {reason}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -75,7 +80,7 @@ def main() -> None:
|
||||
raise RuntimeError(message.result.text)
|
||||
harness.client.request(
|
||||
"session/wait", {"sessionId": session.id}, response_model=WaitResult,
|
||||
on_notification=display, notification_subscription=notifications,
|
||||
on_notification=lambda notice: display(notice, session.id), notification_subscription=notifications,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,37 @@ class RunResult:
|
||||
notifications: list[Notification]
|
||||
|
||||
|
||||
class _PromptActivity:
|
||||
"""Collect an ordinary prompt's notifications from its inbox receipt through idle."""
|
||||
|
||||
def __init__(self, session_id: str, message_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
self.message_id = message_id
|
||||
self.received = False
|
||||
self.events: list[JsonObject] = []
|
||||
self.notifications: list[Notification] = []
|
||||
|
||||
def record(self, notification: Notification, on_notification: Callable[[Notification], None] | None = None) -> bool:
|
||||
if not self.received:
|
||||
if not _is_inbox_receipt(notification, self.session_id, self.message_id):
|
||||
return False
|
||||
self.received = True
|
||||
self.notifications.append(notification)
|
||||
if on_notification is not None:
|
||||
on_notification(notification)
|
||||
if notification.method == "session.event" and notification.payload.get("sessionId") == self.session_id:
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict):
|
||||
self.events.append(event)
|
||||
return True
|
||||
|
||||
def result(self) -> RunResult:
|
||||
return RunResult(
|
||||
session_id=self.session_id, final_response=final_response(self.events),
|
||||
finish_reason=finish_reason(self.events), events=self.events, notifications=self.notifications,
|
||||
)
|
||||
|
||||
|
||||
class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecution]):
|
||||
def __init__(
|
||||
self, client: HarnessClient, session_id: str, request: _PendingRequest,
|
||||
@@ -66,10 +97,7 @@ class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecutio
|
||||
self._closed = False
|
||||
self._released = False
|
||||
self._routed = False
|
||||
self._received = False
|
||||
self._message_id = ""
|
||||
self._notifications: list[Notification] = []
|
||||
self._events: list[JsonObject] = []
|
||||
self._activity: _PromptActivity | None = None
|
||||
self._buffer: deque[Notification] = deque()
|
||||
self._terminal: RunResult | CommandExecution | None = None
|
||||
|
||||
@@ -119,7 +147,7 @@ class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecutio
|
||||
if _is_command_done(notification, self._session_id, self._terminal.command_id):
|
||||
break
|
||||
elif response.get("kind") == "prompt" and isinstance(response.get("messageId"), str) and response["messageId"]:
|
||||
self._message_id = response["messageId"]
|
||||
self._activity = _PromptActivity(self._session_id, response["messageId"])
|
||||
else:
|
||||
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
|
||||
self._ensure_open()
|
||||
@@ -131,25 +159,16 @@ class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecutio
|
||||
self._release()
|
||||
self._closed = True
|
||||
return terminal
|
||||
assert self._activity is not None
|
||||
while True:
|
||||
notification = self._subscription.next()
|
||||
self._ensure_open()
|
||||
if not self._received:
|
||||
if not _is_inbox_receipt(notification, self._session_id, self._message_id):
|
||||
continue
|
||||
self._received = True
|
||||
self._notifications.append(notification)
|
||||
if notification.method == "session.event" and notification.payload.get("sessionId") == self._session_id:
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict):
|
||||
self._events.append(event)
|
||||
if not self._activity.record(notification):
|
||||
continue
|
||||
if (notification.method == "session.status"
|
||||
and notification.payload.get("sessionId") == self._session_id
|
||||
and notification.payload.get("status") == "idle"):
|
||||
self._terminal = RunResult(
|
||||
session_id=self._session_id, final_response=final_response(self._events),
|
||||
finish_reason=finish_reason(self._events), events=self._events, notifications=self._notifications,
|
||||
)
|
||||
self._terminal = self._activity.result()
|
||||
self._release()
|
||||
return notification
|
||||
except BaseException as error:
|
||||
@@ -313,21 +332,6 @@ class Session:
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> RunResult:
|
||||
content_blocks = normalize_input(input)
|
||||
notifications: list[Notification] = []
|
||||
events: list[JsonObject] = []
|
||||
|
||||
def collect(notification: Notification) -> None:
|
||||
notifications.append(notification)
|
||||
if on_notification is not None:
|
||||
on_notification(notification)
|
||||
if (
|
||||
notification.method == "session.event"
|
||||
and notification.payload.get("sessionId") == self.id
|
||||
):
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
|
||||
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
|
||||
message_id = self.harness.client.session_prompt(
|
||||
self.id,
|
||||
@@ -335,14 +339,11 @@ class Session:
|
||||
notification_subscription=subscription,
|
||||
)
|
||||
|
||||
received = False
|
||||
activity = _PromptActivity(self.id, message_id)
|
||||
while True:
|
||||
notification = subscription.next()
|
||||
if not received:
|
||||
if not _is_inbox_receipt(notification, self.id, message_id):
|
||||
continue
|
||||
received = True
|
||||
collect(notification)
|
||||
if not activity.record(notification, on_notification):
|
||||
continue
|
||||
if (
|
||||
notification.method == "session.status"
|
||||
and notification.payload.get("sessionId") == self.id
|
||||
@@ -350,13 +351,7 @@ class Session:
|
||||
):
|
||||
break
|
||||
|
||||
return RunResult(
|
||||
session_id=self.id,
|
||||
final_response=final_response(events),
|
||||
finish_reason=finish_reason(events),
|
||||
events=events,
|
||||
notifications=notifications,
|
||||
)
|
||||
return activity.result()
|
||||
|
||||
|
||||
def _is_inbox_receipt(notification: Notification, session_id: str, message_id: str) -> bool:
|
||||
|
||||
@@ -493,7 +493,7 @@ def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_p
|
||||
|
||||
|
||||
_PERMISSION_FACTS = {"permission/preset", "sandbox/mode", "approval/policy"}
|
||||
_ASK_POLICY = "Approval policy: ask."
|
||||
_ASK_POLICY = "Approval prompts are enabled in this session"
|
||||
_NEVER_POLICY = "Approval prompts are disabled in this session"
|
||||
|
||||
|
||||
@@ -562,8 +562,6 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti
|
||||
_assert_request_permission(requests[index], "workspace-write", "ask")
|
||||
_assert_request_permission(requests[2], "read-only", "ask")
|
||||
_assert_request_permission(requests[3], "danger-full-access", "never")
|
||||
assert 'The approval policy changed from "ask" to "never" (changed by the user).' in _request_texts(requests[3])
|
||||
assert 'The approval policy changed from "never" to "ask" (changed by the user).' in _request_texts(requests[4])
|
||||
for index in [2, 3, 4]:
|
||||
assert requests[index]["system"] == requests[0]["system"]
|
||||
assert requests[index]["tools"] == requests[0]["tools"]
|
||||
@@ -571,6 +569,11 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti
|
||||
assert "/permission" in _request_texts(requests[8])
|
||||
|
||||
events = _read_events(runtime.logs)
|
||||
notices = [event["data"]["content"] for event in _of_type(events, "user/message")
|
||||
if event["data"]["source"]["kind"] == "user-approval"]
|
||||
assert len(notices) == 2
|
||||
for content, request in zip(notices, requests[3:5]):
|
||||
assert all(text in _request_texts(request) for text in _text_blocks(content))
|
||||
executions = [readonly, invalid, repeated, full, restored]
|
||||
assert len(_of_type(events, "command/run")) == len(executions)
|
||||
for execution in executions:
|
||||
@@ -613,12 +616,14 @@ def test_running_permission_change_keeps_the_native_extra_step_and_notifications
|
||||
_assert_request_permission(requests[1], "danger-full-access", "never")
|
||||
assert requests[0]["system"] == requests[1]["system"]
|
||||
assert requests[0]["tools"] == requests[1]["tools"]
|
||||
notice = 'The approval policy changed from "ask" to "never" (changed by the user).'
|
||||
assert notice not in _request_texts(requests[0])
|
||||
assert notice in _request_texts(requests[1])
|
||||
assert runtime.harness.config.env["DSH_PERMISSION_MODE"] == "workspace-write"
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution, "permission")
|
||||
[notice] = [event["data"]["content"] for event in _of_type(events, "user/message")
|
||||
if event["data"]["source"]["kind"] == "user-approval"]
|
||||
for text in _text_blocks(notice):
|
||||
assert text not in _request_texts(requests[0])
|
||||
assert text in _request_texts(requests[1])
|
||||
assert [event["data"] for event in _of_type(events, "step/start")] == [
|
||||
{"turn": 1, "step": 1}, {"turn": 1, "step": 2},
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ from test_commands_runtime import (
|
||||
_command_pair, _of_type, _permission_facts, _read_events, _receive_result,
|
||||
_request_texts, _runtime, _text_blocks,
|
||||
)
|
||||
from test_plan_runtime import _WaitResult, _wait_activity
|
||||
from test_plan_runtime import _wait_activity
|
||||
|
||||
|
||||
def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution:
|
||||
@@ -73,11 +73,13 @@ def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_pa
|
||||
completed = _until(events, "goal/change", "complete")
|
||||
assert completed["goal"]["phase"] == "complete" and completed["roundsStarted"] == 2
|
||||
assert final.entered.wait(_WAIT_SECONDS)
|
||||
waiting = runtime.workers.submit(runtime.harness.client.request, "session/wait",
|
||||
{"sessionId": session.id}, response_model=_WaitResult)
|
||||
assert not waiting.done() and not final.release.is_set()
|
||||
waiting = runtime.harness.client._begin_request("session/wait", {"sessionId": session.id})
|
||||
# A later RPC confirms admission on the same pipe; keep the wait's
|
||||
# response queued so worker scheduling cannot hide an early return.
|
||||
runtime.call(runtime.harness.client.get_working_directory, session.id)
|
||||
assert waiting.waiter.empty() and not final.release.is_set()
|
||||
final.release.set()
|
||||
waiting.result(timeout=_WAIT_SECONDS)
|
||||
assert runtime.call(runtime.harness.client._wait_request, waiting) == {}
|
||||
assert _until(events, "turn/end")["turn"] == 2
|
||||
requests = runtime.model.captured()
|
||||
assert len(requests) == 4
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Run the plan example against the SDK profile with controlled model replies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, Notification
|
||||
import test_commands_runtime as runtime_helpers
|
||||
from test_commands_runtime import _Completion, _ModelServer, _dsh_bin, _of_type, _read_events
|
||||
|
||||
|
||||
_EXAMPLE = Path(__file__).resolve().parents[1] / "examples/plan.py"
|
||||
_PLAN = "# Small change\n\n- Inspect the code.\n- Implement after approval."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["max-tokens", "dismiss"])
|
||||
def test_plan_example_reports_truncation_and_accepts_explicit_dismissal(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], outcome: str,
|
||||
) -> None:
|
||||
example = runpy.run_path(str(_EXAMPLE))
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
logs = tmp_path / "sessions"
|
||||
patch = tmp_path / "persistence.yml"
|
||||
patch.write_text(
|
||||
"- id: session-persistence-jsonl\n config:\n root: " + json.dumps(str(logs))
|
||||
+ "\n compression: none\n", encoding="utf-8",
|
||||
)
|
||||
completion = (_Completion("PARTIAL_PLAN") if outcome == "max-tokens"
|
||||
else _Completion("", tool_call=("exit_plan_mode", {"plan": _PLAN})))
|
||||
if outcome == "max-tokens":
|
||||
response = runtime_helpers._messages_response
|
||||
|
||||
def truncated_response(*args: object) -> bytes:
|
||||
return response(*args).replace(b'"stop_reason": "end_turn"', b'"stop_reason": "max_tokens"')
|
||||
|
||||
monkeypatch.setattr(runtime_helpers, "_messages_response", truncated_response)
|
||||
choices: list[str] = []
|
||||
|
||||
def dismiss(prompt: str) -> str:
|
||||
choices.append(prompt)
|
||||
return "d"
|
||||
|
||||
monkeypatch.setattr("builtins.input", dismiss)
|
||||
monkeypatch.setenv("DSH_TELEMETRY_DISABLED", "1")
|
||||
monkeypatch.setenv("DSH_TOOLS_MODE", "native")
|
||||
monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"),
|
||||
"--workspace", str(workspace), "Plan a small change."])
|
||||
with _ModelServer([completion]) as model:
|
||||
def configured_harness(**options: object) -> DeepSeekHarness:
|
||||
return DeepSeekHarness(
|
||||
**options, dsh_bin=_dsh_bin(), patches=(str(patch),),
|
||||
base_url=model.url, api_key="keyless-plan-example",
|
||||
initialize_timeout_seconds=30, request_timeout_seconds=20, shutdown_timeout_seconds=20,
|
||||
)
|
||||
|
||||
monkeypatch.setitem(example["main"].__globals__, "DeepSeekHarness", configured_harness)
|
||||
if outcome == "max-tokens":
|
||||
with pytest.raises(RuntimeError, match="max-tokens"):
|
||||
example["main"]()
|
||||
else:
|
||||
example["main"]()
|
||||
assert len(model.captured()) == 1
|
||||
[log] = logs.rglob("session*.jsonl")
|
||||
session_id = json.loads(log.read_text(encoding="utf-8").splitlines()[0])["id"]
|
||||
events = _read_events(logs, session_id=session_id)
|
||||
assert [event["data"]["reason"]["kind"] for event in _of_type(events, "turn/end")] == [
|
||||
"max-tokens" if outcome == "max-tokens" else "completed",
|
||||
]
|
||||
assert [event["data"] for event in _of_type(events, "plan/mode")] == [{"active": True}]
|
||||
assert len(choices) == (0 if outcome == "max-tokens" else 1)
|
||||
assert ("PARTIAL_PLAN" if outcome == "max-tokens" else _PLAN) in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_plan_display_rejects_root_abort_without_rejecting_descendant_abort() -> None:
|
||||
display = runpy.run_path(str(_EXAMPLE))["display"]
|
||||
event = {"type": "turn/end", "data": {"turn": 1, "reason": {"kind": "aborted"}}}
|
||||
display(Notification(method="session.event", payload={"sessionId": "child", "event": event}), "root")
|
||||
with pytest.raises(RuntimeError, match="aborted"):
|
||||
display(Notification(method="session.event", payload={"sessionId": "root", "event": event}), "root")
|
||||
Reference in new issue
Block a user