fix(sdk): harden command examples and run wheel regressions
This commit is contained in:
1 parent
e122cfb627
commit
5771a93f2e
24 files changed
+687
-82
No files matched your search
@@ -12,17 +12,17 @@
|
||||
en: ac793c588b13ce87
|
||||
zh: 502015cda46de3ac
|
||||
/deepseek-harness-python-sdk/run-text-and-commands:
|
||||
en: c781ae575e6be4d2
|
||||
zh: cc802c264f8a7a99
|
||||
en: 0c5b04951c9b7d32
|
||||
zh: b049925160bec5f5
|
||||
/deepseek-harness-python-sdk/client-approvals:
|
||||
en: fc69be1beb8abff1
|
||||
zh: 91b56781931a6f68
|
||||
/deepseek-harness-python-sdk/customize-plugins:
|
||||
en: 69a6a6745c6e89c1
|
||||
zh: 85e82a4cc31e16e5
|
||||
en: 0cfa22d329a704db
|
||||
zh: ff04b5e01e2c5936
|
||||
/deepseek-harness-python-sdk/native-user-questions:
|
||||
en: 979ad00fe498b2be
|
||||
zh: 4b656bd268292a13
|
||||
/deepseek-harness-python-sdk/results-and-notifications:
|
||||
en: e3318c54ef33e859
|
||||
zh: 048d2db7bcf5399f
|
||||
en: 6b81899ea865ffd2
|
||||
zh: d9e326a3fb04e696
|
||||
@@ -67,7 +67,7 @@ The call returns `RunResult` for an ordinary prompt's inbox-receipt-to-idle inte
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command callbacks replay Session-tree notifications from subscription through their own `command/done` after the command response, so that interval can include overlapping work. 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -125,7 +125,7 @@ with DeepSeekHarness(
|
||||
|
||||
`provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `reasoning_effort` is an optional non-empty adapter-owned identifier for that exact route; omission preserves the model's own default. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Initialization rejects a missing adapter, unavailable model, or unsupported effort before a prompt runs. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog.
|
||||
|
||||
The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides a platform-selected persistent shell, `read_image`, `working_directory`, local execution, JSONL sessions, and automatic compaction. Text file tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. SDK prompt text does not execute `/compact`.
|
||||
The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides a platform-selected persistent shell, `read_image`, `working_directory`, local execution, JSONL sessions, and automatic compaction. Text file tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. The `sdk-minimal` profile leaves unsupported slash input on the ordinary prompt path.
|
||||
|
||||
## Native user questions
|
||||
|
||||
@@ -135,7 +135,7 @@ Callbacks run on separate threads so the reader can process cancellation and oth
|
||||
|
||||
## Results and notifications
|
||||
|
||||
`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`.
|
||||
For ordinary prompts, `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.
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
|
||||
内容块数组保持原样排队输入,并返回 `RunResult`。例如,`session.run([{"type": "text", "text": "/compact"}])` 将该文本发送给模型,而 `session.run("/compact")` 调用原生压缩。内容块要求 queue 投递;请求 steer 会在发送前抛出 `ValueError`。
|
||||
|
||||
每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令通知在命令响应到达后交付,止于其自身的 `command/done`。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。
|
||||
每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令回调在命令响应到达后,按顺序交付从订阅时刻到自身 `command/done` 的 Session 树通知,因此该区间可能包含重叠工作的通知。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。
|
||||
|
||||
默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 严格执行支持的命令名,不回退为提示词。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
|
||||
@@ -125,7 +125,7 @@ with DeepSeekHarness(
|
||||
|
||||
`provider` 选择指定 Cordis 组合所注册的提供方路由;`model` 是该适配器解析出的模型 ID。`reasoning_effort` 是该确切路由可选的非空适配器自有标识符;省略时保留模型自身的默认值。`max_tokens` 是一个可选的正整数,用于限制根 agent 及其进程内后代在每次请求中输出的 token 数量;省略该参数时,由提供方的默认行为决定输出上限。缺少适配器、模型不可用或推理强度不受支持时,初始化会在提示词运行前拒绝。压缩(compaction)摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方专属的凭据和端点,并选择 pi-ai 已安装 catalog 中存在的任意提供方/模型组合。
|
||||
|
||||
随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它提供按平台选择的持久 shell、`read_image`、`working_directory`、本地执行、JSONL 会话和自动压缩。文本文件工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。SDK 提示词文本不会执行 `/compact`。
|
||||
随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它提供按平台选择的持久 shell、`read_image`、`working_directory`、本地执行、JSONL 会话和自动压缩。文本文件工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。`sdk-minimal` profile 将不支持的斜杠输入保留在普通提示词路径上。
|
||||
|
||||
## 原生用户问答
|
||||
|
||||
@@ -135,7 +135,7 @@ 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`。
|
||||
对于普通提示词,`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`;省略参数则无限等待。超时不取消运行时工作,也不关闭订阅,后续调用仍可接收通知。
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
zh: bdd91202075498a3
|
||||
/python-sdk-example/run-the-minimal-agent:
|
||||
en: ac09bb6bc3ab76a0
|
||||
zh: 6258540acaf4bc37
|
||||
zh: e69d1e2bd796f3bf
|
||||
/python-sdk-example/compact-a-full-sdk-session:
|
||||
en: 7749773f97211ac1
|
||||
zh: 594348ae66a2ef6c
|
||||
@@ -15,11 +15,11 @@
|
||||
en: 1fe5e06c6842a53b
|
||||
zh: ff0836d8cffaeef7
|
||||
/python-sdk-example/review-a-plan:
|
||||
en: 2596f68bb55082c8
|
||||
zh: 1e40ca75e1a0f9b9
|
||||
en: e303821f2432111b
|
||||
zh: f418c686498743d0
|
||||
/python-sdk-example/run-a-goal:
|
||||
en: ae73e9fd9d402047
|
||||
zh: f9694bc6a6e12929
|
||||
en: f4748b8e074f3497
|
||||
zh: 271aa49d20d83a73
|
||||
/python-sdk-example/add-plugins:
|
||||
en: 5e1e30dac13f6c9e
|
||||
zh: 0af98ebb75c88f43
|
||||
@@ -59,7 +59,7 @@ The command changes the Session's policy while the printed launch setting remain
|
||||
<a id="review-a-plan"></a>
|
||||
## Review a plan
|
||||
|
||||
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.
|
||||
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; cancellation or EOF before an answer dismisses the review. Approval allows implementation from a later model step, so choose an isolated workspace.
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/plan.py \
|
||||
@@ -68,7 +68,7 @@ python python/sdk/examples/plan.py \
|
||||
"Plan the requested change and present it for review."
|
||||
```
|
||||
|
||||
The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait` and rejects truncated or failed root turns; its question callback is independent of the protocol reader.
|
||||
The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait` and rejects truncated or failed root turns; its question callback is independent of the protocol reader. Ctrl+C stops the example even while it is waiting for input.
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## Run a goal
|
||||
@@ -82,7 +82,7 @@ python python/sdk/examples/goal.py \
|
||||
"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.
|
||||
`--timeout-seconds` requires a finite positive value and bounds the initial command wait and subsequent 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
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ python python/sdk/examples/minimal.py \
|
||||
- 配合支持图片输入的模型读取 PNG/JPEG/WebP/GIF 文件的 `read_image`
|
||||
- 查询或修改 Session 目录的 `working_directory`
|
||||
|
||||
该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、文本文件工具、本地指令发现、设置、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。[自动上下文压缩](../../../packages/bundle/sdk-minimal/README.zh.md#model-experience)通过额外模型请求生成较早历史的摘要,同时保留完整日志。SDK 提示词文本不会执行斜杠命令。
|
||||
该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、文本文件工具、本地指令发现、设置、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。[自动上下文压缩](../../../packages/bundle/sdk-minimal/README.zh.md#model-experience)通过额外模型请求生成较早历史的摘要,同时保留完整日志。这个最小组合将不支持的斜杠输入保留在普通提示词路径上。
|
||||
|
||||
持久 PTY 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。
|
||||
|
||||
@@ -59,7 +59,7 @@ python python/sdk/examples/permission.py \
|
||||
<a id="review-a-plan"></a>
|
||||
## 审查计划
|
||||
|
||||
[计划示例](plan.py) 通过 `run()` 发送 `/plan <task>`,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。
|
||||
[计划示例](plan.py) 通过 `run()` 发送 `/plan <task>`,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;取消或作答前遇到 EOF 会关闭审查。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/plan.py \
|
||||
@@ -68,7 +68,7 @@ python python/sdk/examples/plan.py \
|
||||
"Plan the requested change and present it for review."
|
||||
```
|
||||
|
||||
原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,并报告根轮次截断或失败;问答回调独立于协议读取线程。
|
||||
原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,并报告根轮次截断或失败;问答回调独立于协议读取线程。等待输入时也可以通过 Ctrl+C 结束示例。
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## 运行目标
|
||||
@@ -82,7 +82,7 @@ python python/sdk/examples/goal.py \
|
||||
"Calculate 17 * 19, verify the result, and complete the goal without changing files."
|
||||
```
|
||||
|
||||
`--timeout-seconds` 限制观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。自动轮次之间存在 idle 间隙,因此仅调用 `session/wait` 不足以等待目标结束。
|
||||
`--timeout-seconds` 必须是有限正数,用于限制首次命令等待及之后的观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。自动轮次之间存在 idle 间隙,因此仅调用 `session/wait` 不足以等待目标结束。
|
||||
|
||||
## 添加插件
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -43,11 +44,12 @@ def main() -> None:
|
||||
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")
|
||||
if not args.objective.strip() or not math.isfinite(args.timeout_seconds) or args.timeout_seconds <= 0:
|
||||
parser.error("objective must be non-blank and timeout-seconds must be finite and 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"},
|
||||
request_timeout_seconds=args.timeout_seconds,
|
||||
) as harness:
|
||||
session = harness.start_session()
|
||||
with harness.client.subscribe_notifications(lambda notice: notice.payload.get("sessionId") == session.id) as notifications:
|
||||
|
||||
+117
-19
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
@@ -19,25 +21,121 @@ class WaitResult(BaseModel):
|
||||
"""The empty session/wait result."""
|
||||
|
||||
|
||||
def review(request: UserQuestionRequest, cancelled: threading.Event) -> dict:
|
||||
"""Let the user approve, give feedback, or dismiss the native plan review."""
|
||||
if cancelled.is_set():
|
||||
return {"cancelled": True}
|
||||
if len(request.questions) != 1 or request.questions[0].get("intent", {}).get("kind") != "plan-review":
|
||||
raise RuntimeError("This example handles plan reviews only")
|
||||
question = request.questions[0]
|
||||
print(question["detail"])
|
||||
class _PlanReviews:
|
||||
"""Own cancellable stdin readers and join review callbacks before interpreter exit."""
|
||||
|
||||
# A buffered reader can consume later answers from a pipe before it exits.
|
||||
_READ_LINE = """import os, sys
|
||||
if sys.stdin.isatty():
|
||||
try:
|
||||
while True:
|
||||
choice = input("[a] Approve, [k] Keep planning, [d] Dismiss: ").strip().lower()
|
||||
if choice == "d":
|
||||
return {"cancelled": True}
|
||||
if choice == "a":
|
||||
return {"answers": [{"id": question["id"], "selected": ["Approve"]}]}
|
||||
if choice == "k":
|
||||
return {"answers": [{"id": question["id"], "selected": ["Keep planning"], "custom": input("Feedback: ")}]}
|
||||
text = input() + '\\n'
|
||||
except EOFError:
|
||||
return {"cancelled": True}
|
||||
text = ''
|
||||
else:
|
||||
line = bytearray()
|
||||
while True:
|
||||
value = os.read(0, 1)
|
||||
if not value:
|
||||
break
|
||||
line.extend(value)
|
||||
if value == b'\\n':
|
||||
break
|
||||
text = line.decode(sys.stdin.encoding or 'utf-8')
|
||||
sys.stdout.buffer.write(text.encode('utf-8'))
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._closed = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._question_lock = threading.Lock()
|
||||
self._readers: list[subprocess.Popen[bytes]] = []
|
||||
self._callbacks: set[threading.Thread] = set()
|
||||
|
||||
def __enter__(self) -> _PlanReviews:
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
with self._lock:
|
||||
self._closed.set()
|
||||
readers = tuple(self._readers)
|
||||
callbacks = tuple(self._callbacks)
|
||||
for reader in readers:
|
||||
self._terminate(reader)
|
||||
for reader in readers:
|
||||
reader.wait()
|
||||
for callback in callbacks:
|
||||
if callback is not threading.current_thread():
|
||||
callback.join()
|
||||
|
||||
@staticmethod
|
||||
def _terminate(reader: subprocess.Popen[bytes]) -> None:
|
||||
if reader.poll() is None:
|
||||
try:
|
||||
reader.kill()
|
||||
except ProcessLookupError as error:
|
||||
# The reader exited between poll and kill.
|
||||
pass
|
||||
|
||||
def _read_line(self, prompt: str, cancelled: threading.Event) -> str | None:
|
||||
with self._lock:
|
||||
if self._closed.is_set() or cancelled.is_set():
|
||||
return None
|
||||
reader = subprocess.Popen(
|
||||
[sys.executable, "-c", self._READ_LINE], stdin=sys.stdin,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
self._readers.append(reader)
|
||||
try:
|
||||
print(prompt, end="", flush=True)
|
||||
while True:
|
||||
if self._closed.is_set() or cancelled.is_set():
|
||||
self._terminate(reader)
|
||||
try:
|
||||
output, errors = reader.communicate(timeout=0.05)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
if self._closed.is_set() or cancelled.is_set():
|
||||
return None
|
||||
if reader.returncode != 0:
|
||||
raise RuntimeError(f"Plan review input reader failed: {errors.decode(errors='replace').strip()}")
|
||||
return output.decode("utf-8").removesuffix("\n").removesuffix("\r") if output else None
|
||||
finally:
|
||||
self._terminate(reader)
|
||||
reader.communicate()
|
||||
with self._lock:
|
||||
self._readers.remove(reader)
|
||||
|
||||
def answer(self, request: UserQuestionRequest, cancelled: threading.Event) -> dict:
|
||||
"""Serialize plan reviews; cancellation or empty stdin dismisses the current question."""
|
||||
with self._lock:
|
||||
if self._closed.is_set() or cancelled.is_set():
|
||||
return {"cancelled": True}
|
||||
self._callbacks = {callback for callback in self._callbacks if callback.is_alive()}
|
||||
self._callbacks.add(threading.current_thread())
|
||||
while not self._question_lock.acquire(timeout=0.05):
|
||||
if self._closed.is_set() or cancelled.is_set():
|
||||
return {"cancelled": True}
|
||||
try:
|
||||
if self._closed.is_set() or cancelled.is_set():
|
||||
return {"cancelled": True}
|
||||
if len(request.questions) != 1 or request.questions[0].get("intent", {}).get("kind") != "plan-review":
|
||||
raise RuntimeError("This example handles plan reviews only")
|
||||
question = request.questions[0]
|
||||
print(question["detail"], flush=True)
|
||||
while True:
|
||||
choice = self._read_line("[a] Approve, [k] Keep planning, [d] Dismiss: ", cancelled)
|
||||
if choice is None or choice.strip().lower() == "d":
|
||||
return {"cancelled": True}
|
||||
if choice.strip().lower() == "a":
|
||||
return {"answers": [{"id": question["id"], "selected": ["Approve"]}]}
|
||||
if choice.strip().lower() == "k":
|
||||
feedback = self._read_line("Feedback: ", cancelled)
|
||||
if feedback is None:
|
||||
return {"cancelled": True}
|
||||
return {"answers": [{"id": question["id"], "selected": ["Keep planning"], "custom": feedback}]}
|
||||
finally:
|
||||
self._question_lock.release()
|
||||
|
||||
|
||||
def display(notification: Notification, session_id: str) -> None:
|
||||
@@ -65,9 +163,9 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
if not args.task.strip():
|
||||
parser.error("task must be non-blank")
|
||||
with DeepSeekHarness(
|
||||
with _PlanReviews() as reviews, DeepSeekHarness(
|
||||
dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()),
|
||||
profile="sdk", model=args.model, on_user_question=review,
|
||||
profile="sdk", model=args.model, on_user_question=reviews.answer,
|
||||
env={"DSH_PERMISSION_MODE": "workspace-write"},
|
||||
) as harness:
|
||||
session = harness.start_session()
|
||||
|
||||
@@ -232,8 +232,9 @@ class Session:
|
||||
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;
|
||||
retain wire order. Command callbacks replay Session-tree notifications
|
||||
from subscription through their own command/done after the response,
|
||||
including overlapping work. Handler errors remain command results;
|
||||
routing, transport and observer failures raise without rolling back work.
|
||||
"""
|
||||
if delivery not in ("queue", "steer"):
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Run native command regressions against installed SDK and runtime wheels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
TEST_FILES = (
|
||||
"test_commands_runtime.py",
|
||||
"test_goal_runtime.py",
|
||||
"test_plan_runtime.py",
|
||||
"test_plan_example.py",
|
||||
"test_plan_input.py",
|
||||
)
|
||||
|
||||
|
||||
class RequireExecutedTests:
|
||||
"""Treat skipped collection or execution as a failed installed-wheel check."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.skipped: set[str] = set()
|
||||
|
||||
def pytest_collectreport(self, report: pytest.CollectReport) -> None:
|
||||
if report.skipped:
|
||||
self.skipped.add(report.nodeid)
|
||||
|
||||
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
|
||||
if report.skipped:
|
||||
self.skipped.add(report.nodeid)
|
||||
|
||||
def pytest_sessionfinish(self, session: pytest.Session) -> None:
|
||||
if self.skipped:
|
||||
print("Installed-wheel native tests skipped: " + ", ".join(sorted(self.skipped)), file=sys.stderr)
|
||||
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Verify wheel imports, then run every selected regression without repository pytest configuration."""
|
||||
tests = Path(__file__).resolve().parent
|
||||
smoke = runpy.run_path(tests.parents[2] / "scripts/smoke-python-runtime.py")
|
||||
for name in ("PYTHONPATH", "DSH_RUNTIME_MODE", "PYTEST_ADDOPTS", "PYTEST_PLUGINS"):
|
||||
os.environ.pop(name, None)
|
||||
os.environ["DSH_COMMANDS_TEST_RUNTIME"] = "installed"
|
||||
os.environ["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
|
||||
previous_cwd = Path.cwd()
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-native-") as temporary:
|
||||
try:
|
||||
os.chdir(temporary)
|
||||
smoke["assert_installed_wheel_environment"]()
|
||||
config = Path(temporary) / "pytest.ini"
|
||||
config.write_text("[pytest]\n", encoding="utf-8")
|
||||
return pytest.main([
|
||||
"-c", str(config), "--noconftest", "--import-mode=prepend", "-ra",
|
||||
*(str(tests / name) for name in TEST_FILES),
|
||||
], plugins=[RequireExecutedTests()])
|
||||
finally:
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Goal observation deadlines and runtime cleanup through the runnable example."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
|
||||
_EXAMPLE = Path(__file__).resolve().parents[1] / "examples/goal.py"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pending", ["command", "goal-event", "wrapup", "complete"])
|
||||
def test_goal_example_bounds_pending_work_and_reaps_runtime(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], pending: str,
|
||||
) -> None:
|
||||
peer = tmp_path / "goal-peer.py"
|
||||
observed = tmp_path / "observed.jsonl"
|
||||
peer.write_text('''import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
pending, observed = sys.argv[1:]
|
||||
|
||||
def send(frame):
|
||||
print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True)
|
||||
|
||||
for line in sys.stdin:
|
||||
frame = json.loads(line)
|
||||
with Path(observed).open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(frame) + "\\n")
|
||||
method = frame["method"]
|
||||
if method == "initialize":
|
||||
send({"id": frame["id"], "result": {"serverInfo": {"name": "goal-example-peer"}}})
|
||||
elif method == "session/query":
|
||||
assert frame["params"]["text"] == "/goal Finish this task"
|
||||
session = frame["params"]["sessionId"]
|
||||
if pending == "command":
|
||||
continue
|
||||
if pending in ("wrapup", "complete"):
|
||||
send({"method": "session.event", "params": {"sessionId": session, "event": {
|
||||
"type": "goal/change", "data": {"operation": "complete"}}}})
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": "goal-command", "result": {"kind": "success", "text": "Goal set"}}}})
|
||||
elif method == "session/wait":
|
||||
if pending == "wrapup":
|
||||
continue
|
||||
send({"method": "session.event", "params": {"sessionId": session, "event": {
|
||||
"type": "assistant/message", "data": {"message": {
|
||||
"content": [{"type": "text", "text": "Closing reply"}]}}}}})
|
||||
send({"id": frame["id"], "result": {}})
|
||||
elif method == "shutdown":
|
||||
send({"id": frame["id"], "result": {}})
|
||||
break
|
||||
else:
|
||||
raise AssertionError(method)
|
||||
''', encoding="utf-8")
|
||||
example = runpy.run_path(str(_EXAMPLE))
|
||||
harnesses: list[DeepSeekHarness] = []
|
||||
|
||||
def configured_harness(**options: object) -> DeepSeekHarness:
|
||||
harness = DeepSeekHarness(
|
||||
**options, _launch_args=(sys.executable, str(peer), pending, str(observed)),
|
||||
initialize_timeout_seconds=10, shutdown_timeout_seconds=10,
|
||||
)
|
||||
harnesses.append(harness)
|
||||
return harness
|
||||
|
||||
monkeypatch.setitem(example["main"].__globals__, "DeepSeekHarness", configured_harness)
|
||||
timeout = "10" if pending == "complete" else "1"
|
||||
monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"),
|
||||
"--workspace", str(tmp_path), "--timeout-seconds", timeout, "Finish this task"])
|
||||
# The outer bound also releases an implementation that forgets its own deadline.
|
||||
with ThreadPoolExecutor(max_workers=1) as workers:
|
||||
execution = workers.submit(example["main"])
|
||||
try:
|
||||
if pending == "complete":
|
||||
execution.result(timeout=30)
|
||||
else:
|
||||
expected = {"command": "session/query timed out", "goal-event": "SDK notification",
|
||||
"wrapup": "session/wait timed out"}[pending]
|
||||
with pytest.raises(TimeoutError, match=expected):
|
||||
execution.result(timeout=30)
|
||||
finally:
|
||||
for harness in harnesses:
|
||||
harness.close()
|
||||
[harness] = harnesses
|
||||
assert harness.client._proc is None
|
||||
frames = [json.loads(line) for line in observed.read_text(encoding="utf-8").splitlines()]
|
||||
methods = [frame["method"] for frame in frames]
|
||||
assert methods == ["initialize", "session/query", *(["session/wait"] if pending in {"wrapup", "complete"} else []), "shutdown"]
|
||||
if pending == "complete":
|
||||
output = capsys.readouterr().out
|
||||
assert "Goal: complete" in output
|
||||
assert "Closing reply" in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout", ["0", "-1", "nan", "inf", "-inf"])
|
||||
def test_goal_example_rejects_unbounded_or_nonpositive_timeout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], timeout: str,
|
||||
) -> None:
|
||||
example = runpy.run_path(str(_EXAMPLE))
|
||||
monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"),
|
||||
f"--timeout-seconds={timeout}", "Finish this task"])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
example["main"]()
|
||||
assert error.value.code == 2
|
||||
assert "timeout-seconds must be finite and positive" in capsys.readouterr().err
|
||||
@@ -40,18 +40,14 @@ def test_plan_example_reports_truncation_and_accepts_explicit_dismissal(
|
||||
return response(*args).replace(b'"stop_reason": "end_turn"', b'"stop_reason": "max_tokens"')
|
||||
|
||||
monkeypatch.setattr(runtime_helpers, "_messages_response", truncated_response)
|
||||
choices: list[str] = []
|
||||
|
||||
def dismiss(prompt: str) -> str:
|
||||
choices.append(prompt)
|
||||
return "d"
|
||||
|
||||
monkeypatch.setattr("builtins.input", dismiss)
|
||||
terminal_input = tmp_path / "stdin.txt"
|
||||
terminal_input.write_text("d\n", encoding="utf-8")
|
||||
monkeypatch.setenv("DSH_TELEMETRY_DISABLED", "1")
|
||||
monkeypatch.setenv("DSH_TOOLS_MODE", "native")
|
||||
monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"),
|
||||
"--workspace", str(workspace), "Plan a small change."])
|
||||
with _ModelServer([completion]) as model:
|
||||
with terminal_input.open(encoding="utf-8") as terminal, _ModelServer([completion]) as model:
|
||||
monkeypatch.setattr(sys, "stdin", terminal)
|
||||
def configured_harness(**options: object) -> DeepSeekHarness:
|
||||
return DeepSeekHarness(
|
||||
**options, dsh_bin=_dsh_bin(), patches=(str(patch),),
|
||||
@@ -73,8 +69,9 @@ def test_plan_example_reports_truncation_and_accepts_explicit_dismissal(
|
||||
"max-tokens" if outcome == "max-tokens" else "completed",
|
||||
]
|
||||
assert [event["data"] for event in _of_type(events, "plan/mode")] == [{"active": True}]
|
||||
assert len(choices) == (0 if outcome == "max-tokens" else 1)
|
||||
assert ("PARTIAL_PLAN" if outcome == "max-tokens" else _PLAN) in capsys.readouterr().out
|
||||
output = capsys.readouterr().out
|
||||
assert ("[a] Approve" in output) == (outcome == "dismiss")
|
||||
assert ("PARTIAL_PLAN" if outcome == "max-tokens" else _PLAN) in output
|
||||
|
||||
|
||||
def test_plan_display_rejects_root_abort_without_rejecting_descendant_abort() -> None:
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Plan example input uses real pipes and releases readers before process exit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_EXAMPLE = Path(__file__).resolve().parents[1] / "examples/plan.py"
|
||||
_WAIT_SECONDS = 15
|
||||
_PEER = '''import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
lock = threading.Lock()
|
||||
stop = threading.Event()
|
||||
wait_id = None
|
||||
serial = 0
|
||||
|
||||
def send(frame):
|
||||
with lock:
|
||||
print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True)
|
||||
|
||||
def ask(session_id):
|
||||
global serial
|
||||
serial += 1
|
||||
send({"id": "ask-" + str(serial), "method": "user-question/ask", "params": {
|
||||
"requestId": "review-" + str(serial), "sessionId": session_id,
|
||||
"questions": [{"id": "plan-review", "question": "Review the plan?", "detail": "# Small change",
|
||||
"options": [{"label": "Approve"}, {"label": "Keep planning"}],
|
||||
"intent": {"kind": "plan-review", "approve": "Approve"}}],
|
||||
}})
|
||||
|
||||
def cancel():
|
||||
while not stop.wait(0.01):
|
||||
if (root / "cancel").exists():
|
||||
send({"method": "user-question.cancelled", "params": {"requestId": "review-1"}})
|
||||
send({"id": wait_id, "result": {}})
|
||||
return
|
||||
|
||||
watcher = threading.Thread(target=cancel)
|
||||
watcher.start()
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
frame = json.loads(line)
|
||||
method = frame.get("method")
|
||||
if method is None:
|
||||
with (root / "answers.jsonl").open("a") as output:
|
||||
output.write(json.dumps(frame) + "\\n")
|
||||
result = frame.get("result", {})
|
||||
answers = result.get("answers", [])
|
||||
if answers and answers[0].get("selected") == ["Keep planning"]:
|
||||
ask(session_id)
|
||||
else:
|
||||
send({"id": wait_id, "result": {}})
|
||||
elif method == "initialize":
|
||||
send({"id": frame["id"], "result": {"serverInfo": {"name": "plan-input-peer"}}})
|
||||
elif method == "session/query":
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": "plan-command", "result": {"kind": "success", "text": "Plan mode entered"}}}})
|
||||
elif method == "session/wait":
|
||||
wait_id = frame["id"]
|
||||
session_id = frame["params"]["sessionId"]
|
||||
ask(session_id)
|
||||
elif method == "shutdown":
|
||||
(root / "shutdown").write_text("done")
|
||||
send({"id": frame["id"], "result": {}})
|
||||
break
|
||||
else:
|
||||
raise AssertionError(method)
|
||||
finally:
|
||||
stop.set()
|
||||
watcher.join()
|
||||
'''
|
||||
_WRAPPER = '''import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
if sys.platform != "win32":
|
||||
import resource
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
|
||||
example_path, peer, root_argument = sys.argv[1:]
|
||||
root = Path(root_argument)
|
||||
example = runpy.run_path(example_path)
|
||||
readers = []
|
||||
popen = subprocess.Popen
|
||||
|
||||
def record_reader(*args, **kwargs):
|
||||
process = popen(*args, **kwargs)
|
||||
if args and len(args[0]) > 1 and args[0][1] == "-c":
|
||||
readers.append(process)
|
||||
return process
|
||||
|
||||
subprocess.Popen = record_reader
|
||||
|
||||
def configured_harness(**options):
|
||||
answer = options.pop("on_user_question")
|
||||
def record_answer(request, cancelled):
|
||||
result = answer(request, cancelled)
|
||||
with (root / "callbacks.jsonl").open("a") as output:
|
||||
output.write(json.dumps(result) + "\\n")
|
||||
return result
|
||||
return DeepSeekHarness(
|
||||
**options, on_user_question=record_answer, _launch_args=(sys.executable, peer, str(root)),
|
||||
initialize_timeout_seconds=5, shutdown_timeout_seconds=1,
|
||||
)
|
||||
|
||||
example["main"].__globals__["DeepSeekHarness"] = configured_harness
|
||||
sys.argv = [example_path, "--dsh-home", str(root / "home"), "--workspace", str(root), "Plan a change."]
|
||||
try:
|
||||
example["main"]()
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
finally:
|
||||
(root / "cleanup.json").write_text(json.dumps({
|
||||
"readers": [{"pid": reader.pid, "returncode": reader.poll()} for reader in readers],
|
||||
"callbacks": [thread.name for thread in threading.enumerate() if thread.name == "dsh-user-question"],
|
||||
}))
|
||||
'''
|
||||
|
||||
|
||||
def _wait_prompt(process: subprocess.Popen[bytes], output: Path, marker: str) -> None:
|
||||
deadline = time.monotonic() + _WAIT_SECONDS
|
||||
while marker not in output.read_text(encoding="utf-8"):
|
||||
assert process.poll() is None, output.read_text(encoding="utf-8")
|
||||
if time.monotonic() >= deadline:
|
||||
raise AssertionError(f"plan input did not reach {marker!r}")
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def _run(
|
||||
tmp_path: Path, data: bytes, *, stage: str | None = None, action: str | None = None, terminal: bool = False,
|
||||
) -> list[dict]:
|
||||
peer = tmp_path / "peer.py"
|
||||
peer.write_text(_PEER, encoding="utf-8")
|
||||
wrapper = tmp_path / "wrapper.py"
|
||||
wrapper.write_text(_WRAPPER, encoding="utf-8")
|
||||
output = tmp_path / "stdout.txt"
|
||||
errors = tmp_path / "stderr.txt"
|
||||
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
with ExitStack() as stack, output.open("wb") as stdout, errors.open("wb") as stderr:
|
||||
if terminal:
|
||||
master, slave = os.openpty()
|
||||
writing = stack.enter_context(os.fdopen(master, "wb", buffering=0))
|
||||
reading = stack.enter_context(os.fdopen(slave, "rb", buffering=0))
|
||||
child_input = reading
|
||||
else:
|
||||
child_input = subprocess.PIPE
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(wrapper), str(_EXAMPLE), str(peer), str(tmp_path)],
|
||||
stdin=child_input, stdout=stdout, stderr=stderr, env=env, start_new_session=os.name != "nt",
|
||||
)
|
||||
if not terminal:
|
||||
assert process.stdin is not None
|
||||
writing = stack.enter_context(process.stdin)
|
||||
try:
|
||||
if data:
|
||||
writing.write(data)
|
||||
writing.flush()
|
||||
if action is None and not terminal:
|
||||
writing.close()
|
||||
elif action is not None:
|
||||
marker = "Feedback: " if stage == "feedback" else "[d] Dismiss: "
|
||||
_wait_prompt(process, output, marker)
|
||||
if action == "interrupt":
|
||||
process.send_signal(signal.SIGINT)
|
||||
else:
|
||||
(tmp_path / "cancel").write_text("cancel")
|
||||
expected = 130 if action == "interrupt" else 0
|
||||
assert process.wait(timeout=_WAIT_SECONDS) == expected, errors.read_text(encoding="utf-8")
|
||||
finally:
|
||||
if os.name != "nt":
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError as error:
|
||||
# Successful example teardown has already removed the process group.
|
||||
pass
|
||||
elif process.poll() is None:
|
||||
taskkill = Path(os.environ["SystemRoot"]) / "System32" / "taskkill.exe"
|
||||
subprocess.run([str(taskkill), "/PID", str(process.pid), "/T", "/F"],
|
||||
stdin=subprocess.DEVNULL, capture_output=True, check=False, timeout=_WAIT_SECONDS)
|
||||
process.wait(timeout=_WAIT_SECONDS)
|
||||
assert "_enter_buffered_busy" not in errors.read_text(encoding="utf-8")
|
||||
assert (tmp_path / "shutdown").read_text() == "done"
|
||||
cleanup = json.loads((tmp_path / "cleanup.json").read_text())
|
||||
assert cleanup["readers"] and all(reader["returncode"] is not None for reader in cleanup["readers"])
|
||||
assert cleanup["callbacks"] == []
|
||||
return [json.loads(line) for line in (tmp_path / "callbacks.jsonl").read_text().splitlines()]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data,expected", [
|
||||
(b"a", [{"answers": [{"id": "plan-review", "selected": ["Approve"]}]}]),
|
||||
(b"d\n", [{"cancelled": True}]),
|
||||
(b"", [{"cancelled": True}]),
|
||||
("invalid\nk\n继续检查\r\nd\n".encode(), [
|
||||
{"answers": [{"id": "plan-review", "selected": ["Keep planning"], "custom": "继续检查"}]},
|
||||
{"cancelled": True},
|
||||
]),
|
||||
])
|
||||
def test_plan_input_preserves_piped_lines_and_eof(tmp_path: Path, data: bytes, expected: list[dict]) -> None:
|
||||
assert _run(tmp_path, data) == expected
|
||||
|
||||
|
||||
def test_plan_input_print_failure_reaps_reader_before_owner_exit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
example = runpy.run_path(str(_EXAMPLE))
|
||||
owner_type = example["_PlanReviews"]
|
||||
readers = []
|
||||
popen = subprocess.Popen
|
||||
|
||||
def record_reader(*args, **kwargs):
|
||||
reader = popen(*args, **kwargs)
|
||||
readers.append(reader)
|
||||
return reader
|
||||
|
||||
def fail_print(*_args, **_kwargs):
|
||||
raise BrokenPipeError("terminal output closed")
|
||||
|
||||
with ExitStack() as stack, monkeypatch.context() as patch:
|
||||
read_fd, write_fd = os.pipe()
|
||||
terminal = stack.enter_context(os.fdopen(read_fd, "r", encoding="utf-8"))
|
||||
stack.enter_context(os.fdopen(write_fd, "w", encoding="utf-8"))
|
||||
patch.setattr(sys, "stdin", terminal)
|
||||
patch.setattr(subprocess, "Popen", record_reader)
|
||||
patch.setitem(owner_type._read_line.__globals__, "print", fail_print)
|
||||
with owner_type() as owner:
|
||||
with pytest.raises(BrokenPipeError, match="terminal output closed"):
|
||||
owner._read_line("Review: ", threading.Event())
|
||||
assert len(readers) == 1 and readers[0].poll() is not None
|
||||
assert owner._readers == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["choice", "feedback"])
|
||||
def test_plan_input_cancellation_reaps_open_pipe_reader(tmp_path: Path, stage: str) -> None:
|
||||
data = b"k\npartial feedback" if stage == "feedback" else b"partial choice"
|
||||
assert _run(tmp_path, data, stage=stage, action="protocol") == [{"cancelled": True}]
|
||||
assert not (tmp_path / "answers.jsonl").exists()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""POSIX terminal input and SIGINT cleanup for the plan example."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from test_plan_input import _run
|
||||
import test_plan_input
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX PTYs and SIGINT delivery")
|
||||
|
||||
|
||||
def test_plan_input_preserves_terminal_feedback(tmp_path: Path) -> None:
|
||||
assert _run(tmp_path, "k\n继续检查\nd\n".encode(), terminal=True) == [
|
||||
{"answers": [{"id": "plan-review", "selected": ["Keep planning"], "custom": "继续检查"}]},
|
||||
{"cancelled": True},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["choice", "feedback"])
|
||||
def test_plan_input_interrupt_reaps_open_pipe_reader(tmp_path: Path, stage: str) -> None:
|
||||
data = b"k\npartial feedback" if stage == "feedback" else b"partial choice"
|
||||
assert _run(tmp_path, data, stage=stage, action="interrupt") == [{"cancelled": True}]
|
||||
assert not (tmp_path / "answers.jsonl").exists()
|
||||
|
||||
|
||||
def test_plan_input_cancellation_ignores_inherited_sigterm_disposition(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
wrapper = test_plan_input._WRAPPER.replace(
|
||||
"import json\n", "import json\nimport signal\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\n", 1,
|
||||
)
|
||||
monkeypatch.setattr(test_plan_input, "_WRAPPER", wrapper)
|
||||
assert _run(tmp_path, b"partial choice", stage="choice", action="protocol") == [{"cancelled": True}]
|
||||
Reference in new issue
Block a user