feat(sdk): support native goal slash commands

This commit is contained in:
LogosVeritas
2026-10-25 21:55:27 +08:00
parent d501108371
commit 9c3971618a
39 changed files with 728 additions and 78 deletions
+4 -4
View File
@@ -12,8 +12,8 @@
en: c4c30913fe2642ef
zh: 0ed42bca37fba60b
/deepseek-harness-python-sdk/query-and-response:
en: 1348c26311dee604
zh: 9290f1d66da1781c
en: c9e73f3b9934032f
zh: bf44fb5d471dbe75
/deepseek-harness-python-sdk/client-approvals:
en: fc69be1beb8abff1
zh: 91b56781931a6f68
@@ -24,5 +24,5 @@
en: 979ad00fe498b2be
zh: 4b656bd268292a13
/deepseek-harness-python-sdk/results-and-notifications:
en: d4f7366c2d260639
zh: 7de99aed2b1bdd0a
en: e3318c54ef33e859
zh: 048d2db7bcf5399f
+6 -2
View File
@@ -61,18 +61,20 @@ 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, and `/plan [off|message]`, 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 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.
`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>`, or `/plan [off|message]` 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)` 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).
`/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).
`/plan` enters plan mode, `/plan off` leaves, and `/plan <message>` enters then submits the trimmed message through native steer. Its `CommandExecution` confirms command completion, not completion of planning or review. To collect a normal activity result, enter with `/plan` then submit a separate ordinary query. For combined input, subscribe before sending and use `session/wait` while keeping the question handler alive, as in the [plan example](examples/README.md#review-a-plan). Plan mode changes guidance and progressively admits `exit_plan_mode`; it does not change permission settings. The default guidance prohibits file edits, including plan documents, while execution remains governed by the sandbox.
`/goal` shows the current goal; `/goal <objective>`, `edit <objective>`, `pause`, `resume`, and `clear` use the native goal handler. Creation and resume can start automatic rounds after the command returns. Subscribe before sending; observe terminal `goal/change` operations, then call `session/wait` for any closing model reply. Idle between rounds is not goal completion. A restored active goal stays disarmed until explicit resume. Goal commands preserve permission settings and startup configuration. See the [goal example](examples/README.md#run-a-goal).
<a id="client-approvals"></a>
## Client approvals
@@ -133,6 +135,8 @@ Callbacks run on separate threads so the reader can process cancellation and oth
`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`.
`NotificationSubscription.next(timeout_seconds=...)` bounds one local notification wait and raises `TimeoutError` on expiry. Omitting the argument waits indefinitely. A timeout neither cancels runtime work nor closes the subscription; a later call can receive subsequent notifications.
`HarnessClient` retains discovered subagent ancestry for the runtime process lifetime. During `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and known descendants in wire order. `RunResult.events` contains root-session events only, so descendant output cannot replace the root response. The low-level `session_prompt()` returns the queued message id immediately; callers that bypass `Session.run()` own the later activity boundary.
The selected home stores profiles, plugins, and every profile-owned durable resource. The full `sdk` profile uses its credentials, settings, and session stores; `sdk-minimal` uses JSONL session storage and stores images under `$DSH_HOME/attachments/v1`, with request-image cache under `$DSH_HOME/cache/attachments`. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources.
+6 -2
View File
@@ -61,18 +61,20 @@ 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]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 及其余输入仍是普通提示词。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。
调用 `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。命令执行时机仍由命令决定。
`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]`,并拒绝裸 `/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)` 严格接受 `/compact`、`/permission <preset>`、`/plan [off|message]` 或 `/goal [objective|clear|edit objective|pause|resume]`,并拒绝裸 `/permission`。另见[压缩示例](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)。
`/plan` 进入计划模式,`/plan off` 退出,`/plan <message>` 进入后通过原生 steer 提交去除首尾空白的正文。返回的 `CommandExecution` 只表示命令完成,不表示规划或审查结束。需要普通活动结果时,可以先用 `/plan` 进入,再单独提交普通 query。组合输入则应在发送前订阅,并在保持问答处理器运行的同时使用 `session/wait`,见[计划示例](examples/README.zh.md#review-a-plan)。计划模式改变指导并渐进披露 `exit_plan_mode`,不修改权限设置。默认指导禁止编辑文件,包括计划文档;实际执行仍由沙箱控制。
`/goal` 显示当前目标;`/goal <objective>`、`edit <objective>`、`pause`、`resume` 和 `clear` 使用原生目标 handler。创建和恢复可在命令返回后启动自动轮次。发送前应订阅通知,观察终态 `goal/change` 操作,再调用 `session/wait` 等待可能的模型收尾回复。轮次之间的 idle 不代表目标完成。恢复的活动目标保持 disarmed,直到显式恢复。目标命令保留权限设置和启动配置。另见[目标示例](examples/README.zh.md#run-a-goal)。
<a id="client-approvals"></a>
## 客户端审批
@@ -133,6 +135,8 @@ with DeepSeekHarness(
`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`。
`NotificationSubscription.next(timeout_seconds=...)` 限制单次本地通知等待,超时抛出 `TimeoutError`;省略参数则无限等待。超时不取消运行时工作,也不关闭订阅,后续调用仍可接收通知。
`HarnessClient` 会在运行时进程的整个生命周期内保留已发现的 subagent 谱系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。
所选 home 保存 profile、插件与每个 profile 自有的持久资源。完整 `sdk` profile 使用其中的凭据、设置与会话存储;`sdk-minimal` 保存 JSONL 会话,将图片存储在 `$DSH_HOME/attachments/v1` 下,请求图片缓存位于 `$DSH_HOME/cache/attachments` 下。需要隔离这些资源时应使用新的 home;独立工作应使用新的会话 ID。同时复用 harness 与会话 ID 会延续持久对话和会话资源。
+5 -2
View File
@@ -15,8 +15,11 @@
en: 3471642d8444d0e9
zh: 7831f28295a6e68a
/python-sdk-example/review-a-plan:
en: b013d5ab5e24bb83
zh: 84ed4333970b9dc4
en: bd0da5138a033eba
zh: f291c3ae8967e936
/python-sdk-example/run-a-goal:
en: ae73e9fd9d402047
zh: f9694bc6a6e12929
/python-sdk-example/add-plugins:
en: 5e1e30dac13f6c9e
zh: 0af98ebb75c88f43
+14
View File
@@ -70,6 +70,20 @@ python python/sdk/examples/plan.py \
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.
<a id="run-a-goal"></a>
## Run a goal
The [goal example](goal.py) submits `/goal <objective>`, prints goal changes and assistant replies, and waits for the final reply after a terminal goal event. It uses the full SDK profile and workspace-write permissions.
```sh
python python/sdk/examples/goal.py \
--dsh-home /absolute/path/to/goal-dsh-home \
--workspace /absolute/path/to/disposable-workspace \
"Calculate 17 * 19, verify the result, and complete the goal without changing files."
```
`--timeout-seconds` bounds observation (300 seconds by default). A timeout reports failure and closes the runtime; it does not mark the goal complete. The persisted goal can be inspected after restoration and explicitly resumed. `session/wait` alone is insufficient because automatic rounds have idle gaps.
## Add plugins
Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes:
+14
View File
@@ -70,6 +70,20 @@ python python/sdk/examples/plan.py \
原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,问答回调独立于协议读取线程。
<a id="run-a-goal"></a>
## 运行目标
[目标示例](goal.py) 提交 `/goal <objective>`,打印目标变化和助手回复,并在终态目标事件后等待最后的回复。示例使用完整 SDK profile 和 workspace-write 权限。
```sh
python python/sdk/examples/goal.py \
--dsh-home /absolute/path/to/goal-dsh-home \
--workspace /absolute/path/to/disposable-workspace \
"Calculate 17 * 19, verify the result, and complete the goal without changing files."
```
`--timeout-seconds` 限制观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。自动轮次之间存在 idle 间隙,因此仅调用 `session/wait` 不足以等待目标结束。
## 添加插件
对同一个显式 home 使用运行时 wheel 包提供的 `dsh` 命令,以进行持久 profile 变更:
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Set a goal and observe automatic rounds through the final model reply."""
from __future__ import annotations
import argparse
import os
import time
from pathlib import Path
from pydantic import BaseModel
from deepseek_harness import CommandExecution, DeepSeekHarness, Notification
from deepseek_harness.api import final_response
class WaitResult(BaseModel):
"""The empty session/wait result."""
def display(notification: Notification) -> dict | None:
"""Print durable assistant output and return the observed root Session event."""
if notification.method != "session.event":
return None
event = notification.payload["event"]
if event["type"] == "assistant/message":
text = final_response([event])
if text:
print(text, flush=True)
elif event["type"] == "goal/change":
print(f"Goal: {event['data']['operation']}", flush=True)
elif event["type"] == "turn/end" and event["data"]["reason"]["kind"] != "completed":
raise RuntimeError(f"Goal activity ended: {event['data']['reason']}")
return event
def main() -> None:
"""Observe goal state before waiting for the final activity interval."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dsh-home", required=True, type=Path)
parser.add_argument("--workspace", type=Path, default=Path.cwd())
parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash"))
parser.add_argument("--timeout-seconds", type=float, default=300)
parser.add_argument("objective", help="Objective for the native /goal command")
args = parser.parse_args()
if not args.objective.strip() or args.timeout_seconds <= 0:
parser.error("objective must be non-blank and timeout-seconds must be positive")
with DeepSeekHarness(
dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()),
profile="sdk", model=args.model, env={"DSH_PERMISSION_MODE": "workspace-write"},
) as harness:
session = harness.start_session()
with harness.client.subscribe_notifications(lambda notice: notice.payload.get("sessionId") == session.id) as notifications:
deadline = time.monotonic() + args.timeout_seconds
session.query(f"/goal {args.objective}")
for message in session.receive_response():
if isinstance(message, CommandExecution):
print(message.result.text, flush=True)
if message.result.kind == "error":
raise RuntimeError(message.result.text)
while True:
event = display(notifications.next(timeout_seconds=max(0, deadline - time.monotonic())))
if event is not None and event["type"] == "goal/change" and event["data"]["operation"] in {
"complete", "block", "pause", "clear",
}:
break
# A terminal goal event can precede the model's closing reply.
harness.client.request(
"session/wait", {"sessionId": session.id}, response_model=WaitResult,
timeout_seconds=max(0, deadline - time.monotonic()),
on_notification=display, notification_subscription=notifications,
)
if __name__ == "__main__":
main()
+6 -2
View File
@@ -720,8 +720,12 @@ class NotificationSubscription:
self.close()
self._notifications.put(error)
def next(self) -> Notification:
item = self._notifications.get()
def next(self, timeout_seconds: float | None = None) -> Notification:
"""Read one notification; an optional timeout stops only this local wait."""
try:
item = self._notifications.get(timeout=timeout_seconds)
except queue.Empty as exc:
raise TimeoutError("timed out waiting for an SDK notification") from exc
if isinstance(item, BaseException):
raise item
return item
+9
View File
@@ -596,6 +596,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe
assert notification.payload["sessionId"] == "other"
def test_notification_timeout_keeps_subscription_available_for_later_events() -> None:
client = HarnessClient()
with client.subscribe_notifications() as subscription:
with pytest.raises(TimeoutError, match="timed out waiting for an SDK notification"):
subscription.next(timeout_seconds=0)
client._handle_message({"jsonrpc": "2.0", "method": "tick", "params": {"value": 1}})
assert subscription.next(timeout_seconds=0).payload == {"value": 1}
def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None:
client = HarnessClient()
with client.subscribe_session_notifications("main") as first:
+1 -1
View File
@@ -103,7 +103,7 @@ def test_low_level_command_preserves_the_line(peer: Path, tmp_path: Path) -> Non
def test_command_rpc_rejection_does_not_fall_back_to_prompt(peer: Path, tmp_path: Path) -> None:
with harness(peer, tmp_path, {"error": {"code": -32602, "message": "unsupported command"}}) as sdk:
with pytest.raises(JsonRpcError, match="unsupported command") as raised:
sdk.client.session_command("main", "/goal")
sdk.client.session_command("main", "/model")
assert raised.value.code == -32602
assert [frame["method"] for frame in frames(tmp_path)] == ["initialize", "session/command", "shutdown"]
+22 -12
View File
@@ -40,7 +40,8 @@ _T = TypeVar("_T")
class _Completion:
text: str
blocked: bool = False
tool_call: tuple[str, dict] | None = None
tool_call: tuple[str, dict] | Callable[[dict], tuple[str, dict]] | None = None
allow_disconnect: bool = False
entered: threading.Event = field(default_factory=threading.Event)
release: threading.Event = field(default_factory=threading.Event)
@@ -98,13 +99,18 @@ class _ModelServer:
completion.entered.set()
# The test owns release. This larger bound only contains a failed test.
assert completion.release.wait(_WAIT_SECONDS * 2), "model barrier was not released"
payload = _messages_response(completion.text, body["model"], ordinal, completion.tool_call)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
self.wfile.flush()
tool_call = completion.tool_call(body) if callable(completion.tool_call) else completion.tool_call
payload = _messages_response(completion.text, body["model"], ordinal, tool_call)
try:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
if not completion.allow_disconnect:
raise
def log_message(self, _format: str, *_args: object) -> None:
pass
@@ -182,6 +188,7 @@ def _runtime(
tmp_path: Path, completions: list[_Completion], *, capture_patch: Path | None = None,
initial_permission: str = "danger-full-access",
on_user_question: UserQuestionHandler | None = None,
max_goal_rounds: int | None = None,
) -> Iterator[_Runtime]:
workspace = tmp_path / "workspace"
workspace.mkdir(exist_ok=True)
@@ -194,6 +201,9 @@ def _runtime(
+ "\n compression: none\n",
encoding="utf-8",
)
if max_goal_rounds is not None:
with patch.open("a", encoding="utf-8") as extra:
extra.write(f"- id: goal\n config:\n defaultMaxGoalRounds: {max_goal_rounds}\n")
patches = (patch,) if capture_patch is None else (patch, capture_patch.resolve())
dsh_bin = _dsh_bin()
with _ModelServer(completions) as model, ThreadPoolExecutor(max_workers=2) as workers:
@@ -456,10 +466,10 @@ def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_pa
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2
def test_unimplemented_slash_remains_plain_text_without_creating_a_goal(tmp_path: Path) -> None:
input_text = "/goal DSH_GOAL_TEXT_INPUT"
def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_path: Path) -> None:
input_text = "/feedback DSH_FEEDBACK_TEXT_INPUT"
following = "DSH_AFTER_PLAN_TEXT_INPUT"
reply_before = "DSH_GOAL_TEXT_REPLY"
reply_before = "DSH_FEEDBACK_TEXT_REPLY"
reply = "DSH_AFTER_PLAN_TEXT_REPLY"
with _runtime(tmp_path, [_Completion(reply_before), _Completion(reply)]) as runtime:
session = runtime.harness.start_session(_SESSION_ID)
@@ -478,7 +488,7 @@ def test_unimplemented_slash_remains_plain_text_without_creating_a_goal(tmp_path
assert initial["tools"] == continued["tools"]
events = _read_events(runtime.logs)
assert not _of_type(events, "command/run")
assert not any(event["type"].startswith("goal/") for event in events)
assert not any(event["type"].startswith("feedback/") for event in events)
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2
+177
View File
@@ -0,0 +1,177 @@
"""Native goal rounds and state changes through the Python SDK profile."""
from __future__ import annotations
import json
import time
from pathlib import Path
from deepseek_harness import CommandExecution, RunResult, Session
from deepseek_harness.client import NotificationSubscription
from test_commands_runtime import (
_Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission,
_command_pair, _of_type, _permission_facts, _read_events, _receive_result,
_request_texts, _runtime, _text_blocks,
)
from test_plan_runtime import _WaitResult, _wait_activity
def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution:
runtime.call(session.query, text)
result = runtime.call(_receive_result, session)
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) -> 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)
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()
final.release.set()
waiting.result(timeout=_WAIT_SECONDS)
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")
result = runtime.call(session.run, "/goal Plain text objective")
assert isinstance(result, RunResult) and result.final_response == "RAW_GOAL_TEXT"
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]))
+1 -1
View File
@@ -141,7 +141,7 @@ def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) ->
assert_released(sdk)
@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/goal", "/permission"])
@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", "/permission"])
@pytest.mark.parametrize("delivery", ["queue", "steer"])
def test_prompt_yields_receipt_through_idle_then_run_result(sdk: DeepSeekHarness, text: str, delivery: str) -> None:
session = sdk.start_session("main")