fix(sdk): tighten slash command observation and regression coverage

This commit is contained in:
LogosVeritas committed 2026-10-25 21:55:28 +08:00
1 parent 9c3971618a
commit f7379ef366
29 files changed
+346 -199

No files matched your search

+2 -2
View File
@@ -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
+2 -2
View File
@@ -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).
+2 -2
View File
@@ -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)。
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>
## 运行目标
+8 -3
View File
@@ -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,
)
+41 -46
View File
@@ -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:
+11 -6
View File
@@ -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},
]
+7 -5
View File
@@ -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
+85
View File
@@ -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")