feat(sdk): route native commands through run

This commit is contained in:
LogosVeritas committed 2026-10-26 00:53:20 +08:00
1 parent b367e53a6c
commit e122cfb627
47 files changed
+880 -1369

No files matched your search

+5 -5
View File
@@ -9,11 +9,11 @@
en: ddf4dabea526cef1
zh: 6e31cab10d2f3ed8
/deepseek-harness-python-sdk/start-a-runtime:
en: c4c30913fe2642ef
zh: 0ed42bca37fba60b
/deepseek-harness-python-sdk/query-and-response:
en: 7cc5feefefd05142
zh: ea346bf354ec266d
en: ac793c588b13ce87
zh: 502015cda46de3ac
/deepseek-harness-python-sdk/run-text-and-commands:
en: c781ae575e6be4d2
zh: cc802c264f8a7a99
/deepseek-harness-python-sdk/client-approvals:
en: fc69be1beb8abff1
zh: 91b56781931a6f68
+14 -12
View File
@@ -33,7 +33,7 @@ The Python SDK has no separate application entrypoint. It launches the bundled `
Every launch requires an explicit Harness home. Pass `dsh_home` or provide a non-empty `DSH_HOME` in the child environment. The SDK deliberately never discovers `~/.dsh`.
```py
from deepseek_harness import DeepSeekHarness
from deepseek_harness import DeepSeekHarness, RunResult
with DeepSeekHarness(
dsh_home="/absolute/path/to/isolated-dsh-home",
@@ -45,10 +45,10 @@ with DeepSeekHarness(
) as harness:
result = harness.run("Say hi.", session_id="example-001")
print(result.final_response)
print(result.final_response if isinstance(result, RunResult) else result.result.text)
```
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary activity remains unbounded after its protocol response, whose wait can be limited with `request_timeout_seconds`. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
`harness.resume_session(saved_session_id)` restores persisted history and returns a `Session` ready for `run()`. Save the original session id and reuse its `dsh_home` and workspace. Restoration preserves the stored directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions raise without creating a replacement. `start_session(id)` only opens a lazy handle; it does not request persisted restoration. This API does not restore shell, browser, or other external processes.
@@ -58,22 +58,24 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
result = session.run("Continue the task.")
```
<a id="query-and-response"></a>
## Query and response
<a id="run-text-and-commands"></a>
## Run text and commands
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.
`session.run(input, on_notification=..., delivery="queue")` is the high-level input entry. Non-blank strings use the runtime's [supported command forms and Desktop aliases](../../packages/sdk/server/README.md#command-input); unrecognized input remains ordinary text. `delivery="steer"` targets the next available step for ordinary text. Command admission and timing remain owned by the native handler.
`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.
The call returns `RunResult` for an ordinary prompt's inbox-receipt-to-idle interval, or `CommandExecution` after a command settles. Use `isinstance(result, RunResult)` before reading `final_response`; otherwise inspect the command's `result.kind`. A command retains its `command_id` and optional `source_event_seq`, rather than selecting an earlier assistant reply.
`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.
Content-block arrays stay literal queued prompts and return `RunResult`. For example, `session.run([{"type": "text", "text": "/compact"}])` sends that text to the model, while `session.run("/compact")` invokes native compaction. Content blocks require queue delivery; a steer request raises `ValueError` before sending.
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).
Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command notifications are delivered after the command response, ending at its own `command/done`. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity.
`/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).
The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` executes supported command names without prompt fallback. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
`/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.
`/permission <preset>` changes only the receiving Session. An unknown preset returns a command error without changing policy. Later input and restoration retain successful changes; other Sessions and the caller's `DeepSeekHarnessConfig` keep their own values. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions).
`/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).
`/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. Enter with `/plan` then run ordinary text when a `RunResult` is needed. For combined input, subscribe before `run()` and use `session/wait` while keeping the question handler alive, as in the [plan example](examples/README.md#review-a-plan). Plan guidance and `exit_plan_mode` follow their native lifecycle without changing permission settings.
`/goal` shows the current goal; `/goal <objective>`, `edit <objective>`, `pause`, `resume`, and `clear` use the native handler. Creation and resume can start automatic rounds after `run()` 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. See the [goal example](examples/README.md#run-a-goal).
<a id="client-approvals"></a>
## Client approvals
+14 -12
View File
@@ -33,7 +33,7 @@ Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的
每次启动都必须显式指定 Harness home。请传入 `dsh_home`,或在子进程环境中提供非空的 `DSH_HOME`。SDK 刻意不会发现 `~/.dsh`。
```py
from deepseek_harness import DeepSeekHarness
from deepseek_harness import DeepSeekHarness, RunResult
with DeepSeekHarness(
dsh_home="/absolute/path/to/isolated-dsh-home",
@@ -45,10 +45,10 @@ with DeepSeekHarness(
) as harness:
result = harness.run("Say hi.", session_id="example-001")
print(result.final_response)
print(result.final_response if isinstance(result, RunResult) else result.result.text)
```
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通活动在协议响应后仍不设时限;`request_timeout_seconds` 可以限制协议响应的等待。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。
`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。
@@ -58,22 +58,24 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
result = session.run("Continue the task.")
```
<a id="query-and-response"></a>
## 查询与接收
<a id="run-text-and-commands"></a>
## 运行文本与命令
调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。运行时识别[支持的命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input);未识别的输入保持普通文本。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。
`session.run(input, on_notification=..., delivery="queue")` 是高层输入入口。非空白字符串使用运行时的[命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input)识别规则;未识别的输入保持普通文本。对普通文本,`delivery="steer"` 指向下一个可用步骤。命令的接纳规则与执行时序仍由原生处理器负责。
`receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。
普通提示词返回其入队回执到下一次 idle 区间的 `RunResult`,命令则在执行结束后返回 `CommandExecution`。读取 `final_response` 前使用 `isinstance(result, RunResult)` 检查类型,否则检查命令的 `result.kind`。命令保留自己的 `command_id` 和可选 `source_event_seq`,不会选取更早的助手回复作为结果。
`CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。
内容块数组保持原样排队输入,并返回 `RunResult`。例如,`session.run([{"type": "text", "text": "/compact"}])` 将该文本发送给模型,而 `session.run("/compact")` 调用原生压缩。内容块要求 queue 投递;请求 steer 会在发送前抛出 `ValueError`。
完整 `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)。
每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令通知在命令响应到达后交付,止于其自身的 `command/done`。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。
`/permission <preset>` 委托给原生权限 handler。未知 preset 返回 `CommandExecution.result.kind="error"`,不改变权限状态。成功的变更属于该 Session:后续 query 和恢复保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。`DeepSeekHarnessConfig` 仍是启动输入,不是共享的实时状态镜像;命令不重写它或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外模型 step。另见[权限示例](examples/README.zh.md#change-session-permissions)。
默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 严格执行支持的命令名,不回退为提示词。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
`/plan` 进入计划模式,`/plan off` 退出,`/plan <message>` 进入后通过原生 steer 提交去除首尾空白的正文。返回的 `CommandExecution` 只表示命令完成,不表示规划或审查结束。需要普通活动结果时,可以先用 `/plan` 进入,再单独提交普通 query。组合输入则应在发送前订阅,并在保持问答处理器运行的同时使用 `session/wait`,见[计划示例](examples/README.zh.md#review-a-plan)。计划模式改变指导并渐进披露 `exit_plan_mode`,不修改权限设置。默认指导禁止编辑文件,包括计划文档;实际执行仍由沙箱控制。
`/permission <preset>` 只修改接收命令的 Session。未知 preset 返回命令错误,不改变策略。后续输入与恢复保留成功的修改,其他 Session 和调用方的 `DeepSeekHarnessConfig` 保留自己的值。运行中的审批策略变化可以注入通知并触发下一模型步骤。见[权限示例](examples/README.zh.md#change-session-permissions)。
`/goal` 显示当前目标;`/goal <objective>`、`edit <objective>`、`pause`、`resume` 和 `clear` 使用原生目标 handler。创建和恢复可在命令返回后启动自动轮次。发送前应订阅通知,观察终态 `goal/change` 操作,再调用 `session/wait` 等待可能的模型收尾回复。轮次之间的 idle 不代表目标完成。恢复的活动目标保持 disarmed,直到显式恢复。目标命令保留权限设置和启动配置。另见[目标示例](examples/README.zh.md#run-a-goal)。
`/plan` 进入计划模式,`/plan off` 退出,`/plan <message>` 进入后通过原生 steer 提交去除首尾空白的正文。其 `CommandExecution` 确认命令执行结束,不代表计划生成或审阅结束。需要 `RunResult` 时,先用 `/plan` 进入,再运行普通文本。组合输入应在 `run()` 前订阅,并保持问答处理器存活,通过 `session/wait` 观察后续工作,见[计划示例](examples/README.zh.md#review-a-plan)。计划指导和 `exit_plan_mode` 遵循原生生命周期,不修改权限设置。
`/goal` 查看当前目标;`/goal <objective>`、`edit <objective>`、`pause`、`resume` 和 `clear` 使用原生处理器。创建和恢复可以在 `run()` 返回后启动自动轮次。发送前订阅,观察终态 `goal/change` 操作后,再调用 `session/wait` 等待可能的模型收尾。轮次之间的 idle 不代表目标完成。恢复的活动目标在显式 resume 前保持 disarmed。见[目标示例](examples/README.zh.md#run-a-goal)。
<a id="client-approvals"></a>
## 客户端审批
+7 -7
View File
@@ -6,17 +6,17 @@
en: c97ffc5ce0f7db05
zh: bdd91202075498a3
/python-sdk-example/run-the-minimal-agent:
en: 4b79fd6576e2759d
en: ac09bb6bc3ab76a0
zh: 6258540acaf4bc37
/python-sdk-example/compact-a-full-sdk-session:
en: ad4d67225570ed01
zh: 0d59f6dac40129d6
en: 7749773f97211ac1
zh: 594348ae66a2ef6c
/python-sdk-example/change-session-permissions:
en: 3471642d8444d0e9
zh: 7831f28295a6e68a
en: 1fe5e06c6842a53b
zh: ff0836d8cffaeef7
/python-sdk-example/review-a-plan:
en: 46758c022608af0f
zh: 09b5a59176c11176
en: 2596f68bb55082c8
zh: 1e40ca75e1a0f9b9
/python-sdk-example/run-a-goal:
en: ae73e9fd9d402047
zh: f9694bc6a6e12929
+6 -6
View File
@@ -25,14 +25,14 @@ The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../packages/bundle/sdk
- `read_image` for PNG/JPEG/WebP/GIF files, with an image-capable model
- `working_directory` to inspect or change the Session directory
The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, text file tools, local instruction discovery, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. [Automatic context compaction](../../../packages/bundle/sdk-minimal/README.md#model-experience) summarizes older history through an additional model request while preserving the complete log. SDK prompt text does not execute slash commands.
The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, text file tools, local instruction discovery, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. [Automatic context compaction](../../../packages/bundle/sdk-minimal/README.md#model-experience) summarizes older history through an additional model request while preserving the complete log. This minimal composition leaves unsupported slash input on the ordinary prompt path.
The persistent PTY can modify any path available to the runtime process, so use a disposable checkout or container.
<a id="compact-a-full-sdk-session"></a>
## Compact a full SDK Session
The [compaction example](compact.py) uses the full `sdk` profile. It reads `README.md` from the selected workspace into a `Session.query()` prompt, sends `/compact` through the same method, then asks a follow-up using the retained context. Each query is paired with `Session.receive_response()`. It uses the same model credentials and `DSH_MODEL` default as the minimal example, and requires an explicit home:
The [compaction example](compact.py) uses the full `sdk` profile. It reads `README.md` from the selected workspace into a `Session.run()` prompt, executes `/compact` through the same method, then asks a follow-up using the retained context. It uses the same model credentials and `DSH_MODEL` default as the minimal example, and requires an explicit home:
```sh
python python/sdk/examples/compact.py \
@@ -40,12 +40,12 @@ python python/sdk/examples/compact.py \
--workspace /absolute/path/to/disposable-workspace
```
The script consumes notifications and prints the command id and outcome separately from the model's responses. A failed turn or command stops the example. See [Query and response](../README.md#query-and-response) for completion and error behavior; `minimal.py` retains its `run()` entry and does not enable command execution.
The script prints command ids and outcomes separately from model responses. A failed turn or command stops the example. See [Run text and commands](../README.md#run-text-and-commands) for completion and error behavior; `minimal.py` uses the same `run()` entry, with capabilities supplied by its selected profile.
<a id="change-session-permissions"></a>
## Change Session permissions
The [permission example](permission.py) starts the full `sdk` profile with a `workspace-write` default, sends `/permission <preset>`, then submits an ordinary query on the same Session. Supply the preset explicitly:
The [permission example](permission.py) starts the full `sdk` profile with a `workspace-write` default, sends `/permission <preset>`, then runs ordinary text on the same Session. Supply the preset explicitly:
```sh
python python/sdk/examples/permission.py \
@@ -54,12 +54,12 @@ python python/sdk/examples/permission.py \
--preset read-only
```
The command changes the Session's policy while the printed launch setting remains `workspace-write`. The later query uses the Session's current policy. Bare `/permission` is ordinary text, and unknown presets produce a command error. See [Query and response](../README.md#query-and-response) for result and state semantics.
The command changes the Session's policy while the printed launch setting remains `workspace-write`. The later input uses the Session's current policy. Bare `/permission` is ordinary text, and unknown presets produce a command error. See [Run text and commands](../README.md#run-text-and-commands) for result and state semantics.
<a id="review-a-plan"></a>
## Review a plan
The [plan example](plan.py) sends `/plan <task>` through query and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; EOF dismisses without approving. Approval allows implementation from a later model step, so choose an isolated workspace.
The [plan example](plan.py) sends `/plan <task>` through `run()` and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; EOF dismisses without approving. Approval allows implementation from a later model step, so choose an isolated workspace.
```sh
python python/sdk/examples/plan.py \
+5 -5
View File
@@ -32,7 +32,7 @@ python python/sdk/examples/minimal.py \
<a id="compact-a-full-sdk-session"></a>
## 压缩完整 SDK Session
[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.query()` 输入,通过同一方法发送 `/compact`,随后使用保留的上下文继续提问。每次 query 都配对调用 `Session.receive_response()`。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home:
[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.run()` 输入,通过同一方法执行 `/compact`,随后使用保留的上下文继续提问。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home:
```sh
python python/sdk/examples/compact.py \
@@ -40,12 +40,12 @@ python python/sdk/examples/compact.py \
--workspace /absolute/path/to/disposable-workspace
```
脚本消费通知,并将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[查询与接收](../README.zh.md#query-and-response);`minimal.py` 保留其 `run()` 入口,不启用命令执行。
脚本将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[运行文本与命令](../README.zh.md#run-text-and-commands);`minimal.py` 使用同一个 `run()` 入口,能力由所选 profile 提供。
<a id="change-session-permissions"></a>
## 修改 Session 权限
[权限示例](permission.py)以 `workspace-write` 为默认值启动完整 `sdk` profile,发送 `/permission <preset>`,随后在同一个 Session 中提交普通 query。请显式指定 preset:
[权限示例](permission.py)以 `workspace-write` 为默认值启动完整 `sdk` profile,发送 `/permission <preset>`,随后在同一个 Session 中运行普通文本。请显式指定 preset:
```sh
python python/sdk/examples/permission.py \
@@ -54,12 +54,12 @@ python python/sdk/examples/permission.py \
--preset read-only
```
命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续 query 使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[查询与接收](../README.zh.md#query-and-response)。
命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续输入使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[运行文本与命令](../README.zh.md#run-text-and-commands)。
<a id="review-a-plan"></a>
## 审查计划
[计划示例](plan.py) 通过 query 发送 `/plan <task>`,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。
[计划示例](plan.py) 通过 `run()` 发送 `/plan <task>`,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。
```sh
python python/sdk/examples/plan.py \
+17 -21
View File
@@ -7,22 +7,21 @@ import argparse
import os
from pathlib import Path
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult
def receive(session: Session) -> None:
"""Consume notifications and print the query's command or activity result."""
for result in session.receive_response():
if isinstance(result, RunResult):
if result.finish_reason != "completed":
raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}")
print(result.final_response)
elif isinstance(result, CommandExecution):
print(f"Command {result.command_id}: {result.result.kind}")
if result.result.kind == "error":
raise RuntimeError(result.result.text)
if result.result.text is not None:
print(result.result.text)
def display(result: RunResult | CommandExecution) -> None:
"""Print the input's result and stop on a reported failure."""
if isinstance(result, CommandExecution):
print(f"Command {result.command_id}: {result.result.kind}")
if result.result.kind == "error":
raise RuntimeError(result.result.text)
if result.result.text is not None:
print(result.result.text)
else:
if result.finish_reason != "completed":
raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}")
print(result.final_response)
def main() -> None:
@@ -50,15 +49,12 @@ def main() -> None:
max_tokens=args.max_tokens,
) as harness:
session = harness.start_session(args.session_id)
session.query(
display(session.run(
"Read this project README and summarize its purpose and development commands without using tools.\n\n"
+ readme.read_text(encoding="utf-8")
)
receive(session)
session.query("/compact")
receive(session)
session.query("What are the project's purpose and development commands? Use the earlier README without tools.")
receive(session)
))
display(session.run("/compact"))
display(session.run("What are the project's purpose and development commands? Use the earlier README without tools."))
if __name__ == "__main__":
+6 -6
View File
@@ -52,12 +52,12 @@ def main() -> None:
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)
execution = session.run(f"/goal {args.objective}")
if not isinstance(execution, CommandExecution):
raise RuntimeError("The SDK profile did not execute the requested command")
print(execution.result.text, flush=True)
if execution.result.kind == "error":
raise RuntimeError(execution.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 {
+7 -2
View File
@@ -7,7 +7,7 @@ import argparse
import os
from pathlib import Path
from deepseek_harness import DeepSeekHarness
from deepseek_harness import CommandExecution, DeepSeekHarness
def main() -> None:
@@ -41,7 +41,12 @@ def main() -> None:
profile=args.profile,
) as harness:
result = harness.run(args.prompt, session_id=args.session_id)
print(result.final_response)
if isinstance(result, CommandExecution):
if result.result.kind == "error":
raise RuntimeError(result.result.text)
print(result.result.text or "")
else:
print(result.final_response)
if __name__ == "__main__":
+17 -23
View File
@@ -5,30 +5,27 @@ from __future__ import annotations
import argparse
import os
from contextlib import closing
from pathlib import Path
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult
def receive(session: Session) -> None:
"""Print a command or activity result and stop on a reported failure."""
with closing(session.receive_response()) as responses:
for result in responses:
if isinstance(result, CommandExecution):
print(f"Command {result.command_id}: {result.result.kind}")
if result.result.kind == "error":
raise RuntimeError(result.result.text)
if result.result.text is not None:
print(result.result.text)
elif isinstance(result, RunResult):
if result.finish_reason != "completed":
raise RuntimeError(f"Agent turn ended with {result.finish_reason}")
print(result.final_response)
def display(result: RunResult | CommandExecution) -> None:
"""Print the input's result and stop on a reported failure."""
if isinstance(result, CommandExecution):
print(f"Command {result.command_id}: {result.result.kind}")
if result.result.kind == "error":
raise RuntimeError(result.result.text)
if result.result.text is not None:
print(result.result.text)
else:
if result.finish_reason != "completed":
raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}")
print(result.final_response)
def main() -> None:
"""Send an explicit permission command, then query the same Session."""
"""Send an explicit permission command, then run another prompt in the same Session."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dsh-home", required=True, type=Path)
parser.add_argument("--workspace", type=Path, default=Path.cwd())
@@ -45,13 +42,10 @@ def main() -> None:
env={"DSH_PERMISSION_MODE": "workspace-write"},
) as harness:
session = harness.start_session()
session.query("Summarize the current file and approval policy in one sentence. Do not use tools.")
receive(session)
session.query(f"/permission {args.preset}")
receive(session)
display(session.run("Summarize the current file and approval policy in one sentence. Do not use tools."))
display(session.run(f"/permission {args.preset}"))
print(f"Launch setting: {harness.config.env['DSH_PERMISSION_MODE']}")
session.query("Summarize the current file and approval policy again. Do not use tools.")
receive(session)
display(session.run("Summarize the current file and approval policy again. Do not use tools."))
if __name__ == "__main__":
+6 -6
View File
@@ -72,12 +72,12 @@ def main() -> None:
) as harness:
session = harness.start_session()
with closing(harness.client.subscribe_session_notifications(session.id)) as notifications:
session.query(f"/plan {args.task}")
for message in session.receive_response():
if isinstance(message, CommandExecution):
print(message.result.text)
if message.result.kind == "error":
raise RuntimeError(message.result.text)
execution = session.run(f"/plan {args.task}")
if not isinstance(execution, CommandExecution):
raise RuntimeError("The SDK profile did not execute the requested command")
print(execution.result.text, flush=True)
if execution.result.kind == "error":
raise RuntimeError(execution.result.text)
harness.client.request(
"session/wait", {"sessionId": session.id}, response_model=WaitResult,
on_notification=lambda notice: display(notice, session.id), notification_subscription=notifications,
+75 -145
View File
@@ -1,14 +1,11 @@
from __future__ import annotations
import uuid
import threading
from collections import deque
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Literal
from typing import Callable, Literal, overload
from .client import HarnessClient, HarnessConfig, NotificationSubscription, UserQuestionHandler, _PendingRequest
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
from .errors import SdkProtocolError, TransportClosedError
from .models import CommandExecution, JsonObject, Notification
@@ -84,101 +81,6 @@ class _PromptActivity:
)
class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecution]):
def __init__(
self, client: HarnessClient, session_id: str, request: _PendingRequest,
subscription: NotificationSubscription,
) -> None:
self._client = client
self._session_id = session_id
self._request = request
self._subscription = subscription
self._lock = threading.Lock()
self._closed = False
self._released = False
self._routed = False
self._activity: _PromptActivity | None = None
self._buffer: deque[Notification] = deque()
self._terminal: RunResult | CommandExecution | None = None
def _release(self, error: BaseException | None = None) -> None:
with self._lock:
if self._released:
return
self._released = True
self._client._discard_request(self._request, error)
if error is None:
self._subscription.close()
else:
self._subscription._cancel(error)
def close(self) -> None:
"""Stop observing this query; the runtime operation is not cancelled."""
with self._lock:
if self._closed:
return
self._closed = True
self._release(TransportClosedError("query response iterator closed"))
def _ensure_open(self) -> None:
if self._closed:
raise StopIteration
def __next__(self) -> Notification | RunResult | CommandExecution:
try:
self._ensure_open()
if not self._routed:
response = self._client._wait_request(self._request)
self._ensure_open()
self._routed = True
if not isinstance(response, dict):
raise SdkProtocolError("session/query response must be a JSON object")
if response.get("kind") == "command":
self._terminal = CommandExecution.model_validate(response.get("execution"))
self._release()
while True:
try:
notification = self._subscription._next_queued()
except TransportClosedError:
break
if notification is None:
break
self._buffer.append(notification)
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._activity = _PromptActivity(self._session_id, response["messageId"])
else:
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
self._ensure_open()
if self._buffer:
return self._buffer.popleft()
if self._terminal is not None:
terminal = self._terminal
self._terminal = None
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._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 = self._activity.result()
self._release()
return notification
except BaseException as error:
locally_closed = self._closed
self.close()
if locally_closed and isinstance(error, (TransportClosedError, StopIteration)):
raise StopIteration
raise
class DeepSeekHarness:
"""Reusable synchronous SDK for running DeepSeek Harness agent turns.
@@ -265,55 +167,35 @@ class DeepSeekHarness:
self._client.resume_session(session_id)
return Session(self, session_id)
@overload
def run(
self, input: list[JsonObject], *, session_id: str | None = None,
on_notification: Callable[[Notification], None] | None = None,
) -> RunResult: ...
@overload
def run(
self, input: str | list[JsonObject], *, session_id: str | None = None,
on_notification: Callable[[Notification], None] | None = None,
delivery: Literal["queue", "steer"] = "queue",
) -> RunResult | CommandExecution: ...
def run(
self,
input: str | list[JsonObject],
*,
session_id: str | None = None,
on_notification: Callable[[Notification], None] | None = None,
) -> RunResult:
return self.start_session(session_id).run(input, on_notification=on_notification)
delivery: Literal["queue", "steer"] = "queue",
) -> RunResult | CommandExecution:
"""Run text or a native command on a fresh or named Session."""
return self.start_session(session_id).run(input, on_notification=on_notification, delivery=delivery)
class Session:
def __init__(self, harness: DeepSeekHarness, session_id: str) -> None:
self.harness = harness
self.id = session_id
self._queries: deque[_QueryResponseIterator] = deque()
self._query_lock = threading.Lock()
def query(self, text: str, *, delivery: Literal["queue", "steer"] = "queue") -> None:
"""Send text for runtime prompt or command routing without waiting for the response.
Pair each call with receive_response(). Delivery selects where ordinary
prompts enter the inbox; commands retain their native execution timing.
"""
if not isinstance(text, str):
raise TypeError("query text must be a string")
if delivery not in ("queue", "steer"):
raise ValueError("query delivery must be queue or steer")
self.harness.start()
client = self.harness.client
with self._query_lock:
subscription = client.subscribe_session_notifications(self.id)
try:
request = client._begin_request("session/query", {"sessionId": self.id, "text": text, "delivery": delivery})
except BaseException:
subscription.close()
raise
self._queries.append(_QueryResponseIterator(client, self.id, request, subscription))
def receive_response(self) -> _QueryResponseIterator:
"""Claim the next query in send order and iterate its notifications and terminal result.
Prompts collect the inbox-receipt-to-idle interval used by run(); commands
finish on their RPC result. Close an unfinished iterator to release its
local observation resources without cancelling the runtime operation.
"""
with self._query_lock:
if not self._queries:
raise RuntimeError("receive_response requires a preceding query")
return self._queries.popleft()
def get_working_directory(self) -> str:
"""Read the effective directory, recovering a missing directory to the Session origin."""
@@ -325,20 +207,68 @@ class Session:
self.harness.start()
return self.harness.client.set_working_directory(self.id, path)
@overload
def run(
self, input: list[JsonObject], *,
on_notification: Callable[[Notification], None] | None = None,
) -> RunResult: ...
@overload
def run(
self, input: str | list[JsonObject], *,
on_notification: Callable[[Notification], None] | None = None,
delivery: Literal["queue", "steer"] = "queue",
) -> RunResult | CommandExecution: ...
def run(
self,
input: str | list[JsonObject],
*,
on_notification: Callable[[Notification], None] | None = None,
) -> RunResult:
content_blocks = normalize_input(input)
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
message_id = self.harness.client.session_prompt(
self.id,
content_blocks,
notification_subscription=subscription,
)
delivery: Literal["queue", "steer"] = "queue",
) -> RunResult | CommandExecution:
"""Run text through native input routing, or queue literal content blocks.
Text returns the prompt's receipt-to-idle RunResult or a settled
CommandExecution; command completion does not wait for later model work.
Content blocks stay literal and accept only queue delivery. Notifications
retain wire order; command notifications are delivered after its response,
up to its own command/done event. Handler errors remain command results;
routing, transport and observer failures raise without rolling back work.
"""
if delivery not in ("queue", "steer"):
raise ValueError("run delivery must be queue or steer")
if not isinstance(input, str) and delivery != "queue":
raise ValueError("content-block input requires queue delivery")
self.harness.start()
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
if isinstance(input, str):
response = self.harness.client._request_raw(
"session/query", {"sessionId": self.id, "text": input, "delivery": delivery},
)
if not isinstance(response, dict):
raise SdkProtocolError("session/query response must be a JSON object")
if response.get("kind") == "command":
execution = CommandExecution.model_validate(response.get("execution"))
while True:
try:
notification = subscription._next_queued()
except TransportClosedError:
break
if notification is None:
break
if on_notification is not None:
on_notification(notification)
if _is_command_done(notification, self.id, execution.command_id):
break
return execution
if response.get("kind") != "prompt" or not isinstance(response.get("messageId"), str) or not response["messageId"]:
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
message_id = response["messageId"]
else:
message_id = self.harness.client.session_prompt(
self.id, input, notification_subscription=subscription,
)
activity = _PromptActivity(self.id, message_id)
while True:
notification = subscription.next()
+25 -62
View File
@@ -39,14 +39,6 @@ class HarnessConfig:
on_user_question: UserQuestionHandler | None = None
@dataclass(slots=True)
class _PendingRequest:
request_id: str
method: str
waiter: queue.Queue[JsonValue | BaseException]
deadline: float | None
class HarnessClient:
"""Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio."""
@@ -340,29 +332,15 @@ class HarnessClient:
notification_filter: NotificationFilter | None = None,
notification_subscription: "NotificationSubscription | None" = None,
) -> JsonValue:
request_id = str(uuid.uuid4())
waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
temp_subscription: NotificationSubscription | None = None
subscription = notification_subscription
with self._lock:
self._responses[request_id] = waiter
if on_notification is not None and subscription is None:
temp_subscription = self.subscribe_notifications(notification_filter)
subscription = temp_subscription
try:
pending = self._begin_request(method, params, timeout_seconds=timeout_seconds)
return self._wait_request(pending, on_notification=on_notification, notification_subscription=subscription)
finally:
if temp_subscription is not None:
temp_subscription.close()
def _begin_request(
self,
method: str,
params: JsonObject | None = None,
*,
timeout_seconds: float | None = None,
) -> _PendingRequest:
request_id = str(uuid.uuid4())
waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
with self._lock:
self._responses[request_id] = waiter
try:
message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
@@ -371,59 +349,48 @@ class HarnessClient:
except BaseException:
with self._lock:
self._responses.pop(request_id, None)
if temp_subscription is not None:
temp_subscription.close()
raise
timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds
deadline = None if timeout is None else time.monotonic() + timeout
return _PendingRequest(request_id, method, waiter, deadline)
def _discard_request(self, pending: _PendingRequest, error: BaseException | None = None) -> None:
with self._lock:
if self._responses.get(pending.request_id) is pending.waiter:
self._responses.pop(pending.request_id)
if error is not None:
pending.waiter.put_nowait(error)
def _wait_request(
self,
pending: _PendingRequest,
*,
on_notification: Callable[[Notification], None] | None = None,
notification_subscription: "NotificationSubscription | None" = None,
) -> JsonValue:
subscription = notification_subscription
try:
while True:
if on_notification is not None and subscription is not None:
subscription.drain(on_notification)
try:
item = pending.waiter.get_nowait()
break
except queue.Empty:
pass
wait_timeout = None
if on_notification is not None:
wait_timeout = 0.05
if pending.deadline is not None:
remaining = pending.deadline - time.monotonic()
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
with self._lock:
self._responses.pop(request_id, None)
diagnostics = self._runtime_diagnostics()
suffix = f"\n{diagnostics}" if diagnostics else ""
raise TimeoutError(
f"{pending.method} timed out waiting for DeepSeek Harness runtime{suffix}"
f"{method} timed out waiting for DeepSeek Harness runtime{suffix}"
)
wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining)
try:
item = pending.waiter.get(timeout=wait_timeout)
item = waiter.get(timeout=wait_timeout)
if on_notification is not None and subscription is not None:
subscription.drain(on_notification)
break
except queue.Empty:
continue
if on_notification is not None and subscription is not None:
subscription.drain(on_notification)
if isinstance(item, BaseException):
raise item
return item
except BaseException:
with self._lock:
self._responses.pop(request_id, None)
if temp_subscription is not None:
temp_subscription.close()
raise
finally:
self._discard_request(pending)
if temp_subscription is not None:
temp_subscription.close()
if isinstance(item, BaseException):
raise item
return item
def _write_message(self, message: JsonObject) -> None:
proc = self._proc
@@ -716,10 +683,6 @@ class NotificationSubscription:
self._closed = True
self._client._unsubscribe_notifications(self._subscription_id)
def _cancel(self, error: BaseException) -> None:
self.close()
self._notifications.put(error)
def next(self, timeout_seconds: float | None = None) -> Notification:
"""Read one notification; an optional timeout stops only this local wait."""
try:
+9 -9
View File
@@ -178,7 +178,7 @@ for line in sys.stdin:
"DEEPSEEK_BASE_URL": "http://127.0.0.1:4321",
},
) as harness:
result = harness.run("say hello", session_id="main")
result = harness.run([{"type": "text", "text": "say hello"}], session_id="main")
assert result.final_response == "hello from runtime"
assert result.finish_reason == "max-tokens"
@@ -252,7 +252,7 @@ for line in sys.stdin:
) as harness:
session = harness.start_session("main")
result = session.run(
"spawn a helper",
[{"type": "text", "text": "spawn a helper"}],
on_notification=lambda notification: seen.append(notification.method),
)
@@ -292,7 +292,7 @@ for line in sys.stdin:
SdkProtocolError,
match=r"turn/end event requires a string data\.reason\.kind",
):
harness.run("reject malformed turn ending", session_id="main")
harness.run([{"type": "text", "text": "reject malformed turn ending"}], session_id="main")
def test_relative_cwd_is_absolute_in_process_environment_and_wire(
@@ -363,7 +363,7 @@ for line in sys.stdin:
_launch_args=(sys.executable, str(script)),
cwd=str(tmp_path),
) as harness:
result = harness.run("spawn a helper", session_id="main")
result = harness.run([{"type": "text", "text": "spawn a helper"}], session_id="main")
assert [notification.method for notification in result.notifications] == [
"session.event",
@@ -413,7 +413,7 @@ for line in sys.stdin:
cwd=str(tmp_path),
) as harness:
result = harness.run(
"delegate recursively",
[{"type": "text", "text": "delegate recursively"}],
session_id="main",
on_notification=lambda notification: seen.append(notification.method),
)
@@ -467,7 +467,7 @@ for line in sys.stdin:
_launch_args=(sys.executable, str(script)),
cwd=str(tmp_path),
) as harness:
result = harness.run("stay in your lane", session_id="main")
result = harness.run([{"type": "text", "text": "stay in your lane"}], session_id="main")
assert result.final_response == "right session"
assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main"] * 4
@@ -499,7 +499,7 @@ for line in sys.stdin:
)
with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness:
result = harness.run("one turn", session_id="main")
result = harness.run([{"type": "text", "text": "one turn"}], session_id="main")
assert harness.client._notifications.qsize() == 0
@@ -539,8 +539,8 @@ for line in sys.stdin:
)
with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness:
first = harness.run("first turn", session_id="main")
second = harness.run("second turn", session_id="main")
first = harness.run([{"type": "text", "text": "first turn"}], session_id="main")
second = harness.run([{"type": "text", "text": "second turn"}], session_id="main")
assert first.final_response == "first"
assert second.final_response == "second"
+2 -2
View File
@@ -129,9 +129,9 @@ def test_command_rejects_malformed_results(peer: Path, tmp_path: Path, response:
sdk.client.session_command("main", "/compact")
def test_run_keeps_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None:
def test_content_blocks_keep_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None:
with harness(peer, tmp_path, {"result": {}}) as sdk:
result = sdk.start_session("main").run("/compact")
result = sdk.start_session("main").run([{"type": "text", "text": "/compact"}])
assert result.final_response == "ordinary prompt"
assert result.finish_reason == "completed"
sent = frames(tmp_path)
+23 -69
View File
@@ -13,7 +13,7 @@ import shutil
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing, contextmanager
from contextlib import contextmanager
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -275,22 +275,6 @@ def _request_texts(request: dict) -> list[str]:
return [text for message in request["messages"] for text in _text_blocks(message["content"])]
def _receive_result(
session: Session, response_claimed: threading.Event | None = None,
) -> RunResult | CommandExecution:
result: RunResult | CommandExecution | None = None
with closing(session.receive_response()) as responses:
if response_claimed is not None:
response_claimed.set()
for item in responses:
assert result is None, "receive_response yielded another item after its terminal result"
if not isinstance(item, Notification):
assert isinstance(item, (RunResult, CommandExecution)), type(item)
result = item
assert result is not None, "receive_response ended without a terminal result"
return result
def _wait_command_done(next_notification: Callable[[], Notification]) -> dict:
while True:
notification = next_notification()
@@ -306,10 +290,8 @@ def test_compact_without_history_returns_without_a_model_turn(tmp_path: Path) ->
session = runtime.harness.start_session(_SESSION_ID)
observer = runtime.harness.client.subscribe_session_notifications(_SESSION_ID)
try:
assert runtime.call(session.query, " \n/压缩\t ", delivery="queue") is None
# Wait independently for completion before starting the query's receiver.
execution = runtime.call(session.run, " \n/压缩\t ", delivery="queue")
done = runtime.call(_wait_command_done, observer.next)
execution = runtime.call(_receive_result, session)
except BaseException:
# Wake a timed-out observer before unregistering its otherwise blocking queue.
runtime.harness.close()
@@ -332,20 +314,12 @@ def test_compact_rejects_busy_agent_before_the_model_is_released(tmp_path: Path)
response = _Completion("DSH_COMMANDS_BUSY_REPLY", blocked=True)
with _runtime(tmp_path, [response]) as runtime:
session = runtime.harness.start_session(_SESSION_ID)
sending = runtime.workers.submit(
session.query, "Hold this request while compaction is attempted.", delivery="queue",
running = runtime.workers.submit(
session.run, "Hold this request while compaction is attempted.", delivery="queue",
)
assert response.entered.wait(_WAIT_SECONDS), runtime.model.failures
assert sending.result(timeout=_WAIT_SECONDS) is None
assert not response.release.is_set(), "query waited for the model before returning"
# Claim the prompt's FIFO slot before issuing the command for another receiver.
receiving = threading.Event()
running = runtime.workers.submit(_receive_result, session, receiving)
assert receiving.wait(_WAIT_SECONDS), runtime.model.failures
assert not running.done()
assert runtime.call(session.query, "/compact", delivery="queue") is None
execution = runtime.call(_receive_result, session)
assert not running.done() and not response.release.is_set()
execution = runtime.call(session.run, "/compact", delivery="queue")
assert isinstance(execution, CommandExecution)
assert execution.result.kind == "error"
assert not response.release.is_set()
@@ -371,18 +345,13 @@ def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None
session = runtime.harness.start_session(_SESSION_ID)
# The replaced span is deliberately larger than the framed summary.
prompt = _HISTORY_MARKER + "\n" + "Keep the original request only in the stored history. " * 256
assert runtime.call(session.query, prompt, delivery="queue") is None
first_result = runtime.call(_receive_result, session)
first_result = runtime.call(session.run, prompt, delivery="queue")
assert isinstance(first_result, RunResult)
assert first_result.final_response == _FIRST_REPLY
sending = runtime.workers.submit(session.query, "/compact", delivery="queue")
compacting = runtime.workers.submit(session.run, "/compact", delivery="queue")
assert summary_response.entered.wait(_WAIT_SECONDS), runtime.model.failures
assert sending.result(timeout=_WAIT_SECONDS) is None
assert not summary_response.release.is_set(), "query waited for the summary before returning"
receiving = threading.Event()
compacting = runtime.workers.submit(_receive_result, session, receiving)
assert receiving.wait(_WAIT_SECONDS), runtime.model.failures
assert not summary_response.release.is_set()
assert not compacting.done(), "command returned before its summary request completed"
summary_response.release.set()
execution = compacting.result(timeout=_WAIT_SECONDS)
@@ -415,8 +384,7 @@ def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None
assert "<compacted-summary>" in checkpoint_text and "</compacted-summary>" in checkpoint_text
assert len(_of_type(checkpointed, "turn/start")) == len(_of_type(checkpointed, "turn/end")) == 1
assert runtime.call(session.query, _CONTINUE_PROMPT, delivery="queue") is None
continued = runtime.call(_receive_result, session)
continued = runtime.call(session.run, _CONTINUE_PROMPT, delivery="queue")
assert isinstance(continued, RunResult)
assert continued.final_response == _CONTINUE_REPLY
first, compaction, following = runtime.model.captured()
@@ -452,8 +420,7 @@ def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_pa
with _runtime(tmp_path, [_Completion(text) for text in replies]) as runtime:
session = runtime.harness.start_session(_SESSION_ID)
for prompt, reply in zip(prompts, replies):
assert runtime.call(session.query, prompt, delivery="queue") is None
result = runtime.call(_receive_result, session)
result = runtime.call(session.run, prompt, delivery="queue")
assert isinstance(result, RunResult)
assert result.final_response == reply
requests = runtime.model.captured()
@@ -473,13 +440,11 @@ def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_p
reply = "DSH_AFTER_PLAN_TEXT_REPLY"
with _runtime(tmp_path, [_Completion(reply_before), _Completion(reply)]) as runtime:
session = runtime.harness.start_session(_SESSION_ID)
assert runtime.call(session.query, input_text, delivery="queue") is None
first = runtime.call(_receive_result, session)
first = runtime.call(session.run, input_text, delivery="queue")
assert isinstance(first, RunResult)
assert first.final_response == reply_before
assert runtime.call(session.query, following, delivery="queue") is None
result = runtime.call(_receive_result, session)
result = runtime.call(session.run, following, delivery="queue")
assert isinstance(result, RunResult)
assert result.final_response == reply
initial, continued = runtime.model.captured()
@@ -510,8 +475,7 @@ def _permission_facts(events: list[dict]) -> list[dict]:
def _permission_command(runtime: _Runtime, session: Session, preset: str) -> CommandExecution:
assert runtime.call(session.query, f"/permission {preset}") is None
result = runtime.call(_receive_result, session)
result = runtime.call(session.run, f"/permission {preset}")
assert isinstance(result, CommandExecution)
return result
@@ -524,8 +488,7 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti
initial_config = dict(runtime.harness.config.env)
for session, text in [(main, "Initial main policy; do not use tools."),
(other, "Initial other policy; do not use tools.")]:
runtime.call(session.query, text)
assert isinstance(runtime.call(_receive_result, session), RunResult)
assert isinstance(runtime.call(session.run, text), RunResult)
readonly = _permission_command(runtime, main, "read-only")
invalid = _permission_command(runtime, main, "no-such-preset")
@@ -535,25 +498,20 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti
assert "unknown preset" in invalid.result.text
assert len(runtime.model.captured()) == 2, "idle permission commands woke the model"
runtime.call(main.query, "Read-only policy after a plain query; do not use tools.")
assert isinstance(runtime.call(_receive_result, main), RunResult)
assert isinstance(runtime.call(main.run, "Read-only policy after a plain query; do not use tools."), RunResult)
full = _permission_command(runtime, main, "danger-full-access")
assert full.result.kind == "success"
assert len(runtime.model.captured()) == 3
runtime.call(main.query, "Full policy after a plain query; do not use tools.")
assert isinstance(runtime.call(_receive_result, main), RunResult)
assert isinstance(runtime.call(main.run, "Full policy after a plain query; do not use tools."), RunResult)
restored = _permission_command(runtime, main, "workspace-write")
assert restored.result.kind == "success"
runtime.call(main.query, "Switched-back policy; do not use tools.")
assert isinstance(runtime.call(_receive_result, main), RunResult)
assert isinstance(runtime.call(main.run, "Switched-back policy; do not use tools."), RunResult)
for session in [other, runtime.harness.start_session("permission-new")]:
runtime.call(session.query, "Report your own policy; do not use tools.")
assert isinstance(runtime.call(_receive_result, session), RunResult)
ordinary = runtime.call(main.run, "/permission danger-full-access")
assert isinstance(runtime.call(session.run, "Report your own policy; do not use tools."), RunResult)
ordinary = runtime.call(main.run, [{"type": "text", "text": "/permission danger-full-access"}])
assert isinstance(ordinary, RunResult)
runtime.call(main.query, "/permission")
assert isinstance(runtime.call(_receive_result, main), RunResult)
assert isinstance(runtime.call(main.run, "/permission"), RunResult)
assert runtime.harness.config.env == initial_config
requests = runtime.model.captured()
@@ -596,11 +554,8 @@ def test_running_permission_change_keeps_the_native_extra_step_and_notifications
second = _Completion("PERMISSION_SWITCH_SEEN")
with _runtime(tmp_path, [first, second], initial_permission="workspace-write") as runtime:
session = runtime.harness.start_session(_SESSION_ID)
runtime.call(session.query, "Wait for the policy change, then finish without tools.")
observing = runtime.workers.submit(session.run, "Wait for the policy change, then finish without tools.")
assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures
claimed = threading.Event()
observing = runtime.workers.submit(_receive_result, session, claimed)
assert claimed.wait(_WAIT_SECONDS)
execution = _permission_command(runtime, session, "danger-full-access")
assert execution.result.kind == "success"
assert not first.release.is_set()
@@ -639,8 +594,7 @@ def test_resumed_permission_survives_different_launch_defaults(tmp_path: Path) -
saved = _permission_facts(_read_events(runtime.logs))
with _runtime(tmp_path, [_Completion("RESUMED_PERMISSION")], initial_permission="danger-full-access") as resumed:
session = resumed.call(resumed.harness.resume_session, _SESSION_ID)
resumed.call(session.query, "Report the restored policy without using tools.")
assert isinstance(resumed.call(_receive_result, session), RunResult)
assert isinstance(resumed.call(session.run, "Report the restored policy without using tools."), RunResult)
[request] = resumed.model.captured()
_assert_request_permission(request, "read-only", "ask")
assert resumed.harness.config.env["DSH_PERMISSION_MODE"] == "danger-full-access"
@@ -55,7 +55,7 @@ for line in sys.stdin:
_launch_args=(sys.executable, str(script)), cwd=str(tmp_path),
env={"MAIN_RESPONSE": expected["finalResponse"], "AUDIT_EVENTS": json.dumps(expected["auditEvents"])},
) as harness:
result = harness.run("Keep the original response.", session_id="sdk-audit")
result = harness.run([{"type": "text", "text": "Keep the original response."}], session_id="sdk-audit")
audit_notifications = [notification for notification in result.notifications
if notification.method == "session.event"
and notification.payload["event"]["type"] in ("plugin:translator/request", "plugin:translator/result")]
+36 -12
View File
@@ -4,21 +4,23 @@ from __future__ import annotations
import json
import time
import threading
import pytest
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,
_command_pair, _of_type, _permission_facts, _read_events,
_request_texts, _runtime, _text_blocks,
)
from test_plan_runtime import _wait_activity
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)
result = runtime.call(session.run, text)
assert isinstance(result, CommandExecution)
assert result.result.kind == kind, result.result.text
return result
@@ -63,7 +65,7 @@ def test_goal_reads_and_invalid_controls_do_not_start_model_work(tmp_path: Path)
_command_pair(events, execution, "goal")
def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path) -> None:
def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
final = _Completion("GOAL_DONE", blocked=True)
with _runtime(tmp_path, [_Completion("PROGRESS"), *_completion_steps(final)],
initial_permission="workspace-write", max_goal_rounds=3) as runtime:
@@ -73,13 +75,35 @@ 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.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()
client = runtime.harness.client
sent = threading.Event()
replied = threading.Event()
wait_ids = set()
write = client._write_message
handle = client._handle_message
def record_send(message: dict) -> None:
is_wait = message.get("method") == "session/wait"
if is_wait:
wait_ids.add(message["id"])
write(message)
if is_wait:
sent.set()
def record_response(message: object) -> None:
if isinstance(message, dict) and "method" not in message and message.get("id") in wait_ids:
replied.set()
handle(message)
monkeypatch.setattr(client, "_write_message", record_send)
monkeypatch.setattr(client, "_handle_message", record_response)
waiting = runtime.workers.submit(client.request, "session/wait", {"sessionId": session.id}, response_model=_WaitResult)
assert sent.wait(_WAIT_SECONDS)
# Observe the reader directly so caller scheduling cannot hide an early response.
runtime.call(client.get_working_directory, session.id)
assert not replied.is_set() and not final.release.is_set()
final.release.set()
assert runtime.call(runtime.harness.client._wait_request, waiting) == {}
assert waiting.result(timeout=_WAIT_SECONDS).model_dump() == {}
assert _until(events, "turn/end")["turn"] == 2
requests = runtime.model.captured()
assert len(requests) == 4
@@ -121,7 +145,7 @@ def test_pause_aborts_then_edit_and_resume_use_revised_goal_without_changing_oth
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")
result = runtime.call(session.run, [{"type": "text", "text": "/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")
+10 -22
View File
@@ -11,7 +11,7 @@ from pydantic import BaseModel
from deepseek_harness import CommandExecution, RunResult, Session, UserQuestionRequest
from test_commands_runtime import (
_Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission,
_command_pair, _of_type, _permission_facts, _read_events, _receive_result,
_command_pair, _of_type, _permission_facts, _read_events,
_request_texts, _runtime, _text_blocks,
)
@@ -25,8 +25,7 @@ class _WaitResult(BaseModel):
def _command(runtime: _Runtime, session: Session, text: str) -> CommandExecution:
assert runtime.call(session.query, text) is None
result = runtime.call(_receive_result, session)
result = runtime.call(session.run, text)
assert isinstance(result, CommandExecution)
assert result.result.kind == "success", result.result.text
return result
@@ -94,8 +93,7 @@ def test_plan_body_uses_native_input_then_off_removes_guidance_but_keeps_exit_to
assert {"exit_plan_mode", "write", "edit"} <= _tool_names(first)
_assert_request_permission(first, "workspace-write", "ask")
_command(runtime, session, "/plan off")
runtime.call(session.query, "Continue after the user changed the mode.")
result = runtime.call(_receive_result, session)
result = runtime.call(session.run, "Continue after the user changed the mode.")
assert isinstance(result, RunResult) and result.final_response == "AFTER_OFF"
second = runtime.model.captured()[1]
assert _GUIDANCE not in _effective_system(second)
@@ -112,11 +110,8 @@ def test_plan_body_steers_the_next_step_of_a_running_turn(tmp_path: Path) -> Non
first = _Completion("FIRST_REPLY", blocked=True)
with _runtime(tmp_path, [first, _Completion("PLANNING_REPLY")], initial_permission="workspace-write") as runtime:
session = runtime.harness.start_session(_SESSION_ID)
runtime.call(session.query, "Start this task without tools.")
observing = runtime.workers.submit(session.run, "Start this task without tools.")
assert first.entered.wait(_WAIT_SECONDS)
claimed = threading.Event()
observing = runtime.workers.submit(_receive_result, session, claimed)
assert claimed.wait(_WAIT_SECONDS)
execution = _command(runtime, session, "/plan Design a smaller change.")
assert not first.release.is_set() and not observing.done()
first.release.set()
@@ -138,11 +133,8 @@ def test_running_bare_selection_does_not_force_a_step_and_can_be_cancelled(tmp_p
first = _Completion("FIRST_REPLY", blocked=True)
with _runtime(tmp_path, [first, _Completion("NEXT_REPLY")], initial_permission="workspace-write") as runtime:
session = runtime.harness.start_session(_SESSION_ID)
runtime.call(session.query, "Start without tools.")
observing = runtime.workers.submit(session.run, "Start without tools.")
assert first.entered.wait(_WAIT_SECONDS)
claimed = threading.Event()
observing = runtime.workers.submit(_receive_result, session, claimed)
assert claimed.wait(_WAIT_SECONDS)
_command(runtime, session, "/plan")
if cancel:
cancelled = _command(runtime, session, "/plan off")
@@ -151,8 +143,7 @@ def test_running_bare_selection_does_not_force_a_step_and_can_be_cancelled(tmp_p
first.release.set()
assert isinstance(observing.result(timeout=_WAIT_SECONDS), RunResult)
assert len(runtime.model.captured()) == 1
runtime.call(session.query, "Inspect the next task without tools.")
assert isinstance(runtime.call(_receive_result, session), RunResult)
assert isinstance(runtime.call(session.run, "Inspect the next task without tools."), RunResult)
assert (_GUIDANCE in _effective_system(runtime.model.captured()[1])) is (not cancel)
events = _read_events(runtime.logs)
assert len(_of_type(events, "turn/start")) == 2
@@ -225,8 +216,7 @@ def test_plan_state_survives_resume_without_changing_permissions(tmp_path: Path)
_command(runtime, session, "/plan")
with _runtime(tmp_path, [_Completion("RESTORED_PLAN")], initial_permission="workspace-write") as resumed:
session = resumed.call(resumed.harness.resume_session, _SESSION_ID)
resumed.call(session.query, "Continue planning without tools.")
assert isinstance(resumed.call(_receive_result, session), RunResult)
assert isinstance(resumed.call(session.run, "Continue planning without tools."), RunResult)
[request] = resumed.model.captured()
assert _GUIDANCE in _effective_system(request)
_assert_request_permission(request, "workspace-write", "ask")
@@ -240,11 +230,9 @@ def test_plan_state_is_local_and_run_keeps_ordinary_text_semantics(tmp_path: Pat
session = runtime.harness.start_session(_SESSION_ID)
_command(runtime, session, "/plan")
other = runtime.harness.start_session("plan-other")
runtime.call(other.query, "Inspect your own task without tools.")
assert isinstance(runtime.call(_receive_result, other), RunResult)
runtime.call(session.query, "Inspect the plan without tools.")
assert isinstance(runtime.call(_receive_result, session), RunResult)
result = runtime.call(session.run, "/plan off")
assert isinstance(runtime.call(other.run, "Inspect your own task without tools."), RunResult)
assert isinstance(runtime.call(session.run, "Inspect the plan without tools."), RunResult)
result = runtime.call(session.run, [{"type": "text", "text": "/plan off"}])
assert isinstance(result, RunResult)
first, second, third = runtime.model.captured()
assert _GUIDANCE not in _effective_system(first)
-391
View File
@@ -1,391 +0,0 @@
"""Split query submission and per-request response observation."""
from __future__ import annotations
import sys
import threading
from pathlib import Path
import pytest
from pydantic import BaseModel, ValidationError
from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, SdkProtocolError
from deepseek_harness.errors import JsonRpcError, TransportClosedError
class Ack(BaseModel):
pass
@pytest.fixture
def peer(tmp_path: Path) -> Path:
script = tmp_path / "queries.py"
script.write_text('''import json
import sys
serial = 0
pending_commands = []
pending_prompts = []
def send(frame):
print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True)
def event(session, type, data):
send({"method": "session.event", "params": {"sessionId": session, "event": {"type": type, "data": data}}})
def command(frame, number):
session = frame["params"]["sessionId"]
identity = "command-" + str(number)
event(session, "command/run", {"commandId": identity})
event(session, "command/done", {"commandId": identity, "kind": "success"})
send({"id": frame["id"], "result": {"kind": "command", "execution": {
"commandId": identity, "result": {"kind": "success", "text": "compacted"}}}})
def receipt(frame, number):
session = frame["params"]["sessionId"]
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
event(session, "agent/inbox/spliced", {"inserted": [{"id": "input-" + str(number)}]})
def finish(frame):
session = frame["params"]["sessionId"]
event(session, "assistant/message", {"message": {"content": [
{"type": "text", "text": "answer for " + frame["params"]["text"]}]}})
event(session, "turn/end", {"turn": 1, "reason": {"kind": "completed"}})
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
for line in sys.stdin:
frame = json.loads(line)
method = frame["method"]
if method == "initialize":
send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}})
elif method == "session/query":
serial += 1
params = frame["params"]
assert set(params) == {"sessionId", "text", "delivery"}
assert params["delivery"] in ("queue", "steer")
text = params["text"]
if text == "hold-command":
pending_commands.append((frame, serial))
elif text == "/compact":
command(frame, serial)
elif text == "silent-command":
send({"id": frame["id"], "result": {"kind": "command", "execution": {
"commandId": "silent", "result": {"kind": "success"}}}})
elif text == "rpc-error":
send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}})
elif text == "bad-route":
send({"id": frame["id"], "result": {"kind": "unknown"}})
elif text == "bad-execution":
send({"id": frame["id"], "result": {"kind": "command", "execution": {"commandId": "c"}}})
else:
result = {"kind": "prompt", "messageId": "input-" + str(serial)}
if text == "fast-prompt":
receipt(frame, serial)
finish(frame)
send({"id": frame["id"], "result": result})
else:
send({"id": frame["id"], "result": result})
receipt(frame, serial)
if text == "hold-prompt":
pending_prompts.append(frame)
else:
finish(frame)
elif method == "release":
for original, number in pending_commands:
command(original, number)
pending_commands.clear()
for original in pending_prompts:
finish(original)
pending_prompts.clear()
send({"id": frame["id"], "result": {}})
elif method == "barrier":
send({"id": frame["id"], "result": {}})
elif method == "disconnect":
send({"id": frame["id"], "result": {}})
break
elif method == "shutdown":
send({"id": frame["id"], "result": {}})
break
else:
raise AssertionError(method)
''', encoding="utf-8")
return script
@pytest.fixture
def sdk(peer: Path, tmp_path: Path):
with DeepSeekHarness(
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
_launch_args=(sys.executable, str(peer)),
) as harness:
yield harness
def release(sdk: DeepSeekHarness) -> None:
sdk.client.request("release", {}, response_model=Ack, timeout_seconds=5)
def assert_released(sdk: DeepSeekHarness) -> None:
assert sdk.client._responses == {}
assert sdk.client._notification_subscribers == {}
def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
assert session.query("hold-command") is None
assert len(sdk.client._responses) == 1
release(sdk)
responses = list(session.receive_response())
assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"]
assert isinstance(responses[-1], CommandExecution)
assert responses[-1].command_id == "command-1"
assert_released(sdk)
@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")
session.query(text, delivery=delivery)
responses = list(session.receive_response())
assert all(isinstance(item, Notification) for item in responses[:-1])
assert responses[0].payload["event"]["type"] == "agent/inbox/spliced"
result = responses[-1]
assert isinstance(result, RunResult)
assert result.final_response == "answer for " + text
assert result.finish_reason == "completed"
assert result.notifications == responses[:-1]
assert len(result.events) == 3
assert_released(sdk)
def test_late_receive_uses_an_arrived_response_before_expired_deadline(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
sdk.client.config.request_timeout_seconds = 0
session.query("/compact")
sdk.client.request("barrier", {}, response_model=Ack, timeout_seconds=5)
assert isinstance(list(session.receive_response())[-1], CommandExecution)
assert_released(sdk)
def test_pending_response_timeout_releases_only_its_observer(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
sdk.client.config.request_timeout_seconds = 0
session.query("hold-command")
with pytest.raises(TimeoutError, match="session/query timed out"):
list(session.receive_response())
assert_released(sdk)
release(sdk)
@pytest.mark.parametrize("text,error", [
("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError),
])
def test_query_errors_arrive_during_receive_and_leave_later_queries_usable(sdk: DeepSeekHarness, text: str, error: type) -> None:
session = sdk.start_session("main")
session.query(text)
with pytest.raises(error):
list(session.receive_response())
assert_released(sdk)
session.query("/compact")
assert isinstance(list(session.receive_response())[-1], CommandExecution)
def test_receivers_claim_fifo_even_when_responses_arrive_out_of_order(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("hold-command")
session.query("/compact")
release(sdk)
first = session.receive_response()
second = session.receive_response()
assert list(second)[-1].command_id == "command-2"
assert list(first)[-1].command_id == "command-1"
assert_released(sdk)
with pytest.raises(RuntimeError, match="preceding query"):
session.receive_response()
def test_command_receiver_stops_at_its_done_before_a_later_prompt(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("/compact")
session.query("ordinary")
sdk.client.request("barrier", {}, response_model=Ack)
command = list(session.receive_response())
assert [item.payload["event"]["type"] for item in command[:-1]] == ["command/run", "command/done"]
assert command[-1].command_id == "command-1"
prompt = list(session.receive_response())
assert isinstance(prompt[-1], RunResult)
assert prompt[-1].final_response == "answer for ordinary"
assert_released(sdk)
def test_command_result_without_notifications_does_not_wait_for_done(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("silent-command")
responses = list(session.receive_response())
assert len(responses) == 1
assert responses[0].command_id == "silent"
assert_released(sdk)
@pytest.mark.parametrize("text", ["/compact", "silent-command"])
def test_late_command_receive_preserves_completed_result_after_eof(sdk: DeepSeekHarness, text: str) -> None:
session = sdk.start_session("main")
session.query(text)
sdk.client.request("disconnect", {}, response_model=Ack)
sdk.client._reader_thread.join(5)
assert not sdk.client._reader_thread.is_alive()
responses = list(session.receive_response())
assert isinstance(responses[-1], CommandExecution)
assert responses[-1].result.kind == "success"
if text == "/compact":
assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"]
else:
assert len(responses) == 1
assert_released(sdk)
def test_command_receive_does_not_hide_other_subscription_errors(sdk: DeepSeekHarness, monkeypatch) -> None:
session = sdk.start_session("main")
session.query("/compact")
sdk.client.request("barrier", {}, response_model=Ack)
receiver = session.receive_response()
def fail():
raise SdkProtocolError("invalid notification")
monkeypatch.setattr(receiver._subscription, "_next_queued", fail)
with pytest.raises(SdkProtocolError, match="invalid notification"):
list(receiver)
assert_released(sdk)
def test_query_and_another_receiver_can_run_while_a_prompt_receiver_waits(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("hold-prompt")
receiver = session.receive_response()
entered = threading.Event()
responses = []
errors = []
def receive() -> None:
try:
for item in receiver:
responses.append(item)
entered.set()
except BaseException as error:
errors.append(error)
thread = threading.Thread(target=receive)
thread.start()
try:
assert entered.wait(5)
session.query("/compact")
assert list(session.receive_response())[-1].command_id == "command-2"
assert thread.is_alive()
release(sdk)
thread.join(5)
assert not thread.is_alive()
assert not errors
assert isinstance(responses[-1], RunResult)
assert responses[-1].final_response == "answer for hold-prompt"
assert_released(sdk)
finally:
receiver.close()
thread.join(5)
def test_iterator_close_before_first_next_releases_request_and_subscription(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("hold-command")
receiver = session.receive_response()
receiver.close()
receiver.close()
assert list(receiver) == []
assert_released(sdk)
release(sdk)
session.query("/compact")
assert list(session.receive_response())[-1].command_id == "command-2"
def test_iterator_close_after_a_prompt_notification_releases_its_subscription(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("hold-prompt")
receiver = session.receive_response()
assert isinstance(next(receiver), Notification)
receiver.close()
assert list(receiver) == []
assert_released(sdk)
release(sdk)
def test_iterator_close_wakes_its_blocked_request_receiver(sdk: DeepSeekHarness, monkeypatch) -> None:
session = sdk.start_session("main")
session.query("hold-command")
receiver = session.receive_response()
entered = threading.Event()
errors = []
original = sdk.client._wait_request
def wait(request, **kwargs):
entered.set()
return original(request, **kwargs)
monkeypatch.setattr(sdk.client, "_wait_request", wait)
def receive() -> None:
try:
assert list(receiver) == []
except BaseException as error:
errors.append(error)
thread = threading.Thread(target=receive)
thread.start()
try:
assert entered.wait(5)
receiver.close()
thread.join(5)
assert not thread.is_alive()
assert not errors
assert_released(sdk)
finally:
receiver.close()
thread.join(5)
def test_terminal_result_releases_resources_before_caller_sends_another_query(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("/compact")
receiver = session.receive_response()
for item in receiver:
if isinstance(item, CommandExecution):
assert_released(sdk)
session.query("/compact")
receiver.close()
assert list(session.receive_response())[-1].command_id == "command-2"
assert_released(sdk)
def test_runtime_close_settles_unreceived_queries(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
session.query("hold-command")
sdk.close()
with pytest.raises(TransportClosedError):
list(session.receive_response())
assert_released(sdk)
def test_send_failure_releases_subscription_without_queueing_a_receiver(sdk: DeepSeekHarness, monkeypatch) -> None:
session = sdk.start_session("main")
original = sdk.client._write_message
def write(message):
if message.get("method") == "session/query":
raise TransportClosedError("write failed")
original(message)
monkeypatch.setattr(sdk.client, "_write_message", write)
with pytest.raises(TransportClosedError, match="write failed"):
session.query("ordinary")
assert_released(sdk)
with pytest.raises(RuntimeError, match="preceding query"):
session.receive_response()
+221
View File
@@ -0,0 +1,221 @@
"""Unified blocking input, native command results and independent run observation."""
from __future__ import annotations
import sys
import threading
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import pytest
from pydantic import BaseModel, ValidationError
from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, SdkProtocolError
from deepseek_harness.errors import JsonRpcError
class Ack(BaseModel):
pass
@pytest.fixture
def peer(tmp_path: Path) -> Path:
script = tmp_path / "queries.py"
script.write_text('''import json
import sys
serial = 0
pending_commands = []
pending_prompts = []
def send(frame):
print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True)
def event(session, type, data):
send({"method": "session.event", "params": {"sessionId": session, "event": {"type": type, "data": data}}})
def command(frame, number):
session = frame["params"]["sessionId"]
identity = "command-" + str(number)
event(session, "command/run", {"commandId": identity})
event(session, "command/done", {"commandId": identity, "kind": "success"})
send({"id": frame["id"], "result": {"kind": "command", "execution": {
"commandId": identity, "result": {"kind": "success", "text": "compacted"}}}})
def receipt(frame, number):
session = frame["params"]["sessionId"]
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
event(session, "agent/inbox/spliced", {"inserted": [{"id": "input-" + str(number)}]})
def finish(frame):
session = frame["params"]["sessionId"]
event(session, "assistant/message", {"message": {"content": [
{"type": "text", "text": "answer for " + frame["params"]["text"]}]}})
event(session, "turn/end", {"turn": 1, "reason": {"kind": "completed"}})
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
for line in sys.stdin:
frame = json.loads(line)
method = frame["method"]
if method == "initialize":
send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}})
elif method == "session/query":
serial += 1
params = frame["params"]
assert set(params) == {"sessionId", "text", "delivery"}
assert params["delivery"] in ("queue", "steer")
text = params["text"]
if text == "hold-command":
pending_commands.append((frame, serial))
elif text == "/compact":
command(frame, serial)
elif text == "silent-command":
send({"id": frame["id"], "result": {"kind": "command", "execution": {
"commandId": "silent", "result": {"kind": "success"}}}})
elif text == "rpc-error":
send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}})
elif text == "bad-route":
send({"id": frame["id"], "result": {"kind": "unknown"}})
elif text == "bad-execution":
send({"id": frame["id"], "result": {"kind": "command", "execution": {"commandId": "c"}}})
else:
result = {"kind": "prompt", "messageId": "input-" + str(serial)}
if text == "fast-prompt":
receipt(frame, serial)
finish(frame)
send({"id": frame["id"], "result": result})
else:
send({"id": frame["id"], "result": result})
receipt(frame, serial)
if text == "hold-prompt":
pending_prompts.append(frame)
else:
finish(frame)
elif method == "release":
for original, number in pending_commands:
command(original, number)
pending_commands.clear()
for original in pending_prompts:
finish(original)
pending_prompts.clear()
send({"id": frame["id"], "result": {}})
elif method == "barrier":
send({"id": frame["id"], "result": {}})
elif method == "disconnect":
send({"id": frame["id"], "result": {}})
break
elif method == "shutdown":
send({"id": frame["id"], "result": {}})
break
else:
raise AssertionError(method)
''', encoding="utf-8")
return script
@pytest.fixture
def sdk(peer: Path, tmp_path: Path):
with DeepSeekHarness(
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
_launch_args=(sys.executable, str(peer)),
) as harness:
yield harness
def release(sdk: DeepSeekHarness) -> None:
sdk.client.request("release", {}, response_model=Ack, timeout_seconds=5)
def assert_released(sdk: DeepSeekHarness) -> None:
assert sdk.client._responses == {}
assert sdk.client._notification_subscribers == {}
@pytest.mark.parametrize("entry", ["session", "harness"])
def test_run_returns_command_without_waiting_for_idle(sdk: DeepSeekHarness, entry: str) -> None:
notifications = []
result = (sdk.run("/compact", session_id="main", on_notification=notifications.append)
if entry == "harness" else sdk.start_session("main").run("/compact", on_notification=notifications.append))
assert isinstance(result, CommandExecution)
assert result.result.kind == "success"
assert [notice.payload["event"]["type"] for notice in notifications] == ["command/run", "command/done"]
assert_released(sdk)
@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", " /compact extra "])
def test_run_preserves_ordinary_text_and_result(sdk: DeepSeekHarness, text: str) -> None:
notifications = []
result = sdk.start_session("main").run(text, on_notification=notifications.append)
assert isinstance(result, RunResult)
assert result.final_response == "answer for " + text
assert result.finish_reason == "completed"
assert result.notifications == notifications
assert notifications[0].payload["event"]["type"] == "agent/inbox/spliced"
assert_released(sdk)
def test_run_accepts_steer_for_text(sdk: DeepSeekHarness) -> None:
result = sdk.start_session("main").run("next step", delivery="steer")
assert isinstance(result, RunResult)
assert result.final_response == "answer for next step"
def test_concurrent_runs_keep_their_own_results(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
entered = threading.Event()
with ThreadPoolExecutor(max_workers=2) as workers:
first = workers.submit(session.run, "hold-prompt", on_notification=lambda notice: entered.set())
try:
assert entered.wait(5), "the first run must observe its receipt"
second = workers.submit(session.run, "/compact")
assert isinstance(second.result(timeout=5), CommandExecution)
finally:
release(sdk)
result = first.result(timeout=5)
assert isinstance(result, RunResult)
assert result.final_response == "answer for hold-prompt"
assert_released(sdk)
def test_command_result_needs_no_notifications(sdk: DeepSeekHarness) -> None:
notices = []
result = sdk.start_session("main").run("silent-command", on_notification=notices.append)
assert isinstance(result, CommandExecution)
assert notices == []
assert_released(sdk)
@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError)])
def test_run_reports_routing_failures_without_retry(sdk: DeepSeekHarness, text: str, error: type[Exception]) -> None:
session = sdk.start_session("main")
with pytest.raises(error):
session.run(text)
assert_released(sdk)
assert isinstance(session.run("/compact"), CommandExecution)
@pytest.mark.parametrize("text", ["ordinary", "/compact"])
def test_observer_error_releases_run_subscription(sdk: DeepSeekHarness, text: str) -> None:
def reject(_notice: Notification) -> None:
raise RuntimeError("observer failed")
with pytest.raises(RuntimeError, match="observer failed"):
sdk.start_session("main").run(text, on_notification=reject)
assert_released(sdk)
assert isinstance(sdk.start_session("main").run("after observer"), RunResult)
def test_command_timeout_keeps_runtime_responsive(sdk: DeepSeekHarness) -> None:
sdk.client.config.request_timeout_seconds = 0.05
with pytest.raises(TimeoutError, match="session/query timed out"):
sdk.start_session("main").run("hold-command")
sdk.client.config.request_timeout_seconds = 5
assert_released(sdk)
release(sdk)
assert isinstance(sdk.start_session("main").run("/compact"), CommandExecution)
def test_run_requires_supported_delivery(sdk: DeepSeekHarness) -> None:
session = sdk.start_session("main")
with pytest.raises(ValueError, match="delivery"):
session.run("ordinary", delivery="unknown")
with pytest.raises(ValueError, match="content-block"):
session.run([{"type": "text", "text": "ordinary"}], delivery="steer")