Merge master into feat/subagent-names
This commit is contained in:
commit
4514b99d9c
2829 files changed
+180072
-13757
No files matched your search
@@ -6,14 +6,14 @@
|
||||
en: 1ea1d8e6d2c14b02
|
||||
zh: 8e0b9903158667a1
|
||||
/deepseek-harness-runtime-bin/installed-commands-and-artifacts:
|
||||
en: 29707be30d04287f
|
||||
zh: c9dfd1e37f31078b
|
||||
en: 68407e833c62ee1f
|
||||
zh: 9590f73d7daf8032
|
||||
/deepseek-harness-runtime-bin/python-module-api:
|
||||
en: 78a200e328d96637
|
||||
zh: f3b7a0b69b7a39e0
|
||||
/deepseek-harness-runtime-bin/packaged-profile-resolution:
|
||||
en: f1123b8835b57bbe
|
||||
zh: 05af612c33c5d855
|
||||
en: 0f476fb87a1a3349
|
||||
zh: 359dbd9b24b18b46
|
||||
/deepseek-harness-runtime-bin/build-and-distribution:
|
||||
en: a41e8eb7e99b9532
|
||||
zh: 2789d8f43b1b54be
|
||||
@@ -25,7 +25,7 @@ primary_runtime = download_primary_runtime()
|
||||
|
||||
`DSH_RESOURCE_CACHE` selects an absolute cache root, defaulting to `~/.cache/deepseek-harness/resources`. Resource identities include the release's locked inputs. Downloads verify archive hashes and package identities, preserve executable permissions, and publish completed directories atomically. Failed downloads can be retried. `DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS` bounds each archive download, including its body; the default is `300000`, and accepted values are integers from `1` to `2147483647`. Downloads follow the Harness proxy environment policy. Installed wheel files are never modified. Release versions have separate cache directories; unused versions require manual removal. Downloads use the locked upstream URLs without a registry or mirror override.
|
||||
|
||||
On subsequent launches the packaged bootstrap exposes downloaded resources to the SDK profile. `DSH_PRIMARY_RUNTIME` overrides the cached authoring environment; an empty value disables its query. `DSH_OFFICE_SIDECAR` can select an absolute sidecar directory with its standalone Node. Users may provide their own Python environment and document libraries without downloading the authoring environment. Office skills are available when either resource is selected; authoring-only deployments expose the skills with the Kit CLI disabled. External authoring payloads retain the `primary-runtime/` plus sibling `office-skills/` layout. The SDK reads resources in place without copying them into `DSH_HOME`. The `sdk-minimal` profile does not mount these providers.
|
||||
On subsequent launches the packaged bootstrap exposes downloaded resources to the SDK profile. `DSH_PRIMARY_RUNTIME` overrides the cached authoring environment; an empty value disables its query and package-manager selection. `DSH_OFFICE_SIDECAR` can select an absolute sidecar directory with its standalone Node. Users may provide their own Python environment and document libraries without downloading the authoring environment. Office skills are available when either resource is selected; authoring-only deployments expose the skills with the Kit CLI disabled. External authoring payloads retain the `primary-runtime/` plus sibling `office-skills/` layout. The SDK reads resources in place without copying them into `DSH_HOME`. The `sdk-minimal` profile does not mount these providers.
|
||||
|
||||
Skill selection is independent of delivery: project, custom-directory, and user filesystem skills override same-name bundled skills. An SDK patch can disable only the Office provider while retaining the Python query:
|
||||
|
||||
@@ -58,7 +58,7 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr
|
||||
|
||||
Python and the packaged bootstrap share the resource layout: `<cache>/<identity>/office/`, `node/bin/node[.exe]`, and `node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js`. Automatic Office cache discovery requires the `complete` marker to match the manifest identity; an explicit `DSH_OFFICE_SIDECAR` requires the Node and CLI files. The Python bootstrap resolves the Office kit from its explicitly downloaded or selected sidecar so native helpers and URL Workers use real filesystem paths. The same resource preparation implementation serves repository builds and explicit Python downloads. The kit owns document operations; the Office smoke retrieves CLI paths from the loaded skill and executes capabilities and DOCX conversion with an empty PATH.
|
||||
|
||||
External profile management uses `dsh plugin --profile <name> ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not.
|
||||
External profile management uses `dsh plugin --profile <name> ...`. After explicit `download_primary_runtime()` preparation, the packaged launcher supplies the selected payload's standalone Node and pnpm to this command and the SDK `plugin_manager` tool. It prepends the standalone Node directory only in package-operation subprocesses, including build scripts; ordinary tools retain their original `PATH`. `DSH_PRIMARY_RUNTIME` overrides the cached selection, and an empty value disables it. A malformed or incomplete selected payload fails startup rather than silently selecting system pnpm. Without a selected Node/pnpm payload, package operations use `pnpm` on `PATH` or explicit `plugin-manager.pnpmCommand`. Downloads never start implicitly. Ordinary SDK/profile execution and listing/toggling existing plugins do not require pnpm. Every model-facing management action still requires full access or a client approval responder; saved changes affect the shared SDK profile on subsequent launches.
|
||||
|
||||
## Build and distribution
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ primary_runtime = download_primary_runtime()
|
||||
|
||||
`DSH_RESOURCE_CACHE` 指定绝对缓存根目录,默认为 `~/.cache/deepseek-harness/resources`。资源身份包含发布版本锁定的输入。下载会校验归档哈希和包身份、保留执行权限,并原子发布完整目录。下载失败后可重试。`DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS` 限制每个归档的下载耗时,包含响应内容;默认值为 `300000`,接受 `1` 到 `2147483647` 的整数。下载遵循 Harness 代理环境策略。已安装的 wheel 文件不会被修改。不同发布版本使用独立缓存目录;不再使用的版本需手动删除。下载使用锁定的上游 URL,不提供 registry 或镜像覆盖。
|
||||
|
||||
后续启动时,打包 bootstrap 将已下载资源提供给 SDK profile。`DSH_PRIMARY_RUNTIME` 覆盖缓存中的创作环境;空值禁用其查询。`DSH_OFFICE_SIDECAR` 可选择包含独立 Node 的绝对 sidecar 目录。用户可自行提供 Python 环境和文档库,无需下载创作环境。选择任一种资源即可使用 Office skills;仅有创作环境时,skills 中的 Kit CLI 被禁用。外部创作载荷保留 `primary-runtime/` 及同级 `office-skills/` 布局。SDK 原位读取资源,不复制到 `DSH_HOME`。`sdk-minimal` profile 不挂载这些提供方。
|
||||
后续启动时,打包 bootstrap 将已下载资源提供给 SDK profile。`DSH_PRIMARY_RUNTIME` 覆盖缓存中的创作环境;空值禁用其查询和包管理器选择。`DSH_OFFICE_SIDECAR` 可选择包含独立 Node 的绝对 sidecar 目录。用户可自行提供 Python 环境和文档库,无需下载创作环境。选择任一种资源即可使用 Office skills;仅有创作环境时,skills 中的 Kit CLI 被禁用。外部创作载荷保留 `primary-runtime/` 及同级 `office-skills/` 布局。SDK 原位读取资源,不复制到 `DSH_HOME`。`sdk-minimal` profile 不挂载这些提供方。
|
||||
|
||||
选择 skills 与交付资源相互独立:项目、自定义目录和用户文件系统 skills 优先于同名随包 skills。SDK patch 可以仅禁用 Office 提供方,同时保留 Python 查询:
|
||||
|
||||
@@ -58,7 +58,7 @@ Office 操作技能通过 profile 的 shell 命令调用配置的 Node 和 Kit C
|
||||
|
||||
Python 与打包的 bootstrap 共享资源布局:`<cache>/<identity>/office/`、`node/bin/node[.exe]` 与 `node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js`。自动发现 Office 缓存要求 `complete` 标记匹配清单 identity;显式 `DSH_OFFICE_SIDECAR` 要求 Node 与 CLI 文件存在。Python bootstrap 从显式下载或选择的 sidecar 解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。仓库构建与显式 Python 下载共用资源准备实现。kit 负责文档操作;Office smoke 从已加载技能获取 CLI 路径,并在空 PATH 下执行 capabilities 和 DOCX 转换。
|
||||
|
||||
外部 profile 管理使用 `dsh plugin --profile <name> ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。
|
||||
外部 profile 管理使用 `dsh plugin --profile <name> ...`。显式调用 `download_primary_runtime()` 准备资源后,打包启动器会将所选载荷的独立 Node 和 pnpm 提供给该命令及 SDK `plugin_manager` 工具。独立 Node 目录仅加入包操作子进程(包括构建脚本)的环境;普通工具保留原有 `PATH`。`DSH_PRIMARY_RUNTIME` 覆盖缓存选择,空值禁用它。所选载荷格式错误或不完整时启动失败,不会静默改用系统 pnpm。没有选中包含 Node/pnpm 的载荷时,包操作使用 `PATH` 中的 pnpm 或显式 `plugin-manager.pnpmCommand`。不会隐式触发下载。普通 SDK/profile 运行及列举/切换现有插件不需要 pnpm。每个面向模型的管理操作仍要求完整权限或客户端审批应答方;保存的变更影响后续启动使用的共享 SDK profile。
|
||||
|
||||
## 构建与分发
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { registerHooks } from 'node:module'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { delimiter, dirname, isAbsolute, join } from 'node:path'
|
||||
import { isSea } from 'node:sea'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
@@ -96,8 +96,18 @@ if (resource !== undefined) {
|
||||
if (cache !== undefined && existsSync(join(cache, 'primary', 'complete'))) {
|
||||
process.env.DSH_BUNDLED_PRIMARY_RUNTIME = join(cache, 'primary', 'primary-runtime')
|
||||
}
|
||||
let packageManager
|
||||
const primary = process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME
|
||||
if (primary !== undefined && primary !== '') {
|
||||
const { resolvePrimaryRuntime } = await import('@deepseek-ai/dsh-tool-workspace-dependencies')
|
||||
const { node, pnpm } = await resolvePrimaryRuntime(primary)
|
||||
if (node !== undefined && pnpm !== undefined) {
|
||||
packageManager = { command: node, args: [pnpm],
|
||||
env: { PATH: `${dirname(node)}${delimiter}${process.env.PATH ?? ''}` } }
|
||||
}
|
||||
}
|
||||
const { runCli } = await import('@deepseek-ai/dsh/lib/bin.js')
|
||||
await runCli()
|
||||
await runCli({ packageManager })
|
||||
} else {
|
||||
Reflect.deleteProperty(process.env, selectorName)
|
||||
const { runSelectedSubprocessRunner } = await import('@deepseek-ai/dsh-subprocess-local/runner')
|
||||
|
||||
+13
-10
@@ -9,17 +9,20 @@
|
||||
en: ddf4dabea526cef1
|
||||
zh: 6e31cab10d2f3ed8
|
||||
/deepseek-harness-python-sdk/start-a-runtime:
|
||||
en: 1ae09bebf0f9e19b
|
||||
zh: 55a0d557a2cae4d8
|
||||
en: ac793c588b13ce87
|
||||
zh: 502015cda46de3ac
|
||||
/deepseek-harness-python-sdk/run-text-and-commands:
|
||||
en: b2c847c1452e137d
|
||||
zh: c595c5e82151d0cb
|
||||
/deepseek-harness-python-sdk/client-approvals:
|
||||
en: fc69be1beb8abff1
|
||||
zh: 91b56781931a6f68
|
||||
en: 1d18c09832dd4a42
|
||||
zh: 6e7260b650a1f987
|
||||
/deepseek-harness-python-sdk/customize-plugins:
|
||||
en: 69a6a6745c6e89c1
|
||||
zh: 85e82a4cc31e16e5
|
||||
en: 0cfa22d329a704db
|
||||
zh: ff04b5e01e2c5936
|
||||
/deepseek-harness-python-sdk/native-user-questions:
|
||||
en: d9c60c5f43755782
|
||||
zh: 78c82635209ed22d
|
||||
en: 979ad00fe498b2be
|
||||
zh: 4b656bd268292a13
|
||||
/deepseek-harness-python-sdk/results-and-notifications:
|
||||
en: d4f7366c2d260639
|
||||
zh: 7de99aed2b1bdd0a
|
||||
en: 6b81899ea865ffd2
|
||||
zh: d9e326a3fb04e696
|
||||
+29
-4
@@ -48,7 +48,7 @@ with DeepSeekHarness(
|
||||
print(result.final_response)
|
||||
```
|
||||
|
||||
`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,9 +58,32 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
result = session.run("Continue the task.")
|
||||
```
|
||||
|
||||
<a id="run-text-and-commands"></a>
|
||||
## Run text and commands
|
||||
|
||||
`session.run(input, on_notification=...)` is the public prompt-or-command input entry for strings and content-block arrays. Both use the runtime's [supported command forms and Desktop aliases](../../packages/sdk/server/README.md#command-input); unrecognized input remains an ordinary prompt with its original block order. Ordinary input, including images, uses the existing prompt queue. Command admission and timing remain owned by the native handler.
|
||||
|
||||
Every call returns `RunResult`, including the receiving `session_id`, root events and Session-tree notifications. `final_response` contains the final assistant text, or an empty string when the activity emits none. State-only commands return the native handler's result text. `finish_reason` reports the terminal outcome. Native command identities and outcomes remain in `command/run` and `command/done` events. A completion without its matching command lifecycle events raises `SdkProtocolError` instead of returning a partial result.
|
||||
|
||||
Content-block arrays use the same command routing as strings. Both `session.run([{"type": "text", "text": "/compact"}])` and `session.run("/compact")` invoke native compaction and return `RunResult`. To bypass native command routing, call the low-level `client.session_prompt(session_id, blocks)` and manage subsequent activity; runtime skill recognition still applies.
|
||||
|
||||
Select a model that supports image input. Submit a command and image in the existing content-block array, for example `session.run([{"type": "text", "text": "/plan Analyze this image."}, {"type": "image", "data": image_base64, "mimeType": "image/png"}])`; `harness.run()` accepts the same input. Plan input and goal creation or editing accept images; `/plan off`, goal controls, compact, and permission commands reject them through the [native attachment rules](../../packages/sdk/server/README.md#command-input). Native command input supports encoded raster images, not general file uploads or client-supplied attachment references.
|
||||
|
||||
Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the input's Session-tree activity in wire order; the interval can include overlapping work. Calls may overlap without consuming each other's results. Native refusals return `finish_reason="error"`; routing, protocol, transport and observer failures raise. Malformed native-command responses raise `SdkProtocolError`, retaining their validation cause. 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 input 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. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
|
||||
|
||||
`/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).
|
||||
|
||||
`/plan` enters plan mode, `/plan off` leaves, and `/plan <message>` enters then submits the trimmed message through native steer. `run()` waits for submitted model activity, including native review and approved continuation; no separate subscription or `session/wait` call is required. Keep `on_user_question` available for review, as in the [plan example](examples/README.md#review-a-plan). Plans submitted through `exit_plan_mode` arrive in the review question's `detail`; `final_response` contains the final assistant text. Plan mode does not change permission settings.
|
||||
|
||||
`/goal` shows the current goal. Creation and resume through `run()` wait across automatic rounds until the goal stops and its closing activity finishes. Completion returns `completed`, pause or clear returns `aborted`, and blocking or lost activation reports failure. State-only controls return without waiting for unrelated activity. 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
|
||||
|
||||
For an interactive full-profile example that explicitly prepares Node/pnpm and answers permission requests while a turn runs, see [`examples/manage_plugins.py`](examples/manage_plugins.py). It uses the existing stdio approval protocol, asks before each operation, and closes the runtime before joining its request worker. Plugin configuration takes effect after runtime restart.
|
||||
|
||||
This low-level example receives one approval and rejects it. Use a dedicated request-handling thread or loop for a full run: `next_request()` blocks, and the request handler must run concurrently with `session/wait` or a high-level `run()`. Set `approval_requests=True` on `HarnessClient.initialize()` or `DeepSeekHarnessConfig` only when that handler is ready. Omission preserves the runtime's answerer chain. Decisions are one-shot; invalid responses and lost connections fail closed, and cancellation discards late replies. `ApprovalRequestResult` validates response values when constructing decisions.
|
||||
|
||||
```py
|
||||
@@ -106,17 +129,19 @@ 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
|
||||
|
||||
Pass `on_user_question` to `DeepSeekHarnessConfig`, `DeepSeekHarness`, or `HarnessConfig` to answer the built-in `ask_user_question` tool through the native SDK protocol. The callback receives a `UserQuestionRequest` (`request_id`, `session_id`, and the original `questions`) and a `threading.Event` signalling cancellation. Return `UserQuestionAnswer` or a JSON object shaped as `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}`. Each original question id must appear exactly once. The application supplies its UI or simulated-user policy; the SDK does not choose answers.
|
||||
Pass `on_user_question` to `DeepSeekHarnessConfig`, `DeepSeekHarness`, or `HarnessConfig` to answer the built-in `ask_user_question` tool through the native SDK protocol. The callback receives a `UserQuestionRequest` (`request_id`, `session_id`, and the original `questions`) and a `threading.Event` signalling cancellation. Return `UserQuestionAnswer` or a JSON object shaped as `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}`. Each original question id must appear exactly once. Return `{ "cancelled": true }` to dismiss instead of answering; this preserves the native plan-review dismissal, which ends the turn while keeping plan mode active. The application supplies its UI or simulated-user policy; the SDK does not choose answers.
|
||||
|
||||
Callbacks run on separate threads so the reader can process cancellation and other requests. They may run concurrently and may make further client requests. Release callback-owned work when the cancellation Event is set. Closing the client signals outstanding callbacks and gives their workers a shared 0.5-second join window; callbacks that ignore cancellation may continue, but their late answers cannot reach the runtime or a later process. A callback exception becomes a protocol error, and an absent callback retains existing native answerers or reports `NO_PROVIDER`.
|
||||
|
||||
## 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.
|
||||
|
||||
`HarnessClient` retains discovered subagent ancestry for the runtime process lifetime. During `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and known descendants in wire order. `RunResult.events` contains root-session events only, so descendant output cannot replace the root response. The low-level `session_prompt()` returns the queued message id immediately; callers that bypass `Session.run()` own the later activity boundary.
|
||||
|
||||
|
||||
+29
-4
@@ -48,7 +48,7 @@ with DeepSeekHarness(
|
||||
print(result.final_response)
|
||||
```
|
||||
|
||||
`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,9 +58,32 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
result = session.run("Continue the task.")
|
||||
```
|
||||
|
||||
<a id="run-text-and-commands"></a>
|
||||
## 运行文本与命令
|
||||
|
||||
`session.run(input, on_notification=...)` 是字符串和内容块数组共用的公开提示词/命令输入入口。两者都使用运行时的[命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input)识别规则;未识别的输入保持普通提示词及原始内容块顺序。普通输入(包括图片)使用既有的提示词队列。命令的接纳规则与执行时序仍由原生处理器负责。
|
||||
|
||||
每次调用都返回 `RunResult`,包含接收输入的 `session_id`、根会话事件和会话树通知。`final_response` 包含最终助手文本;活动未输出助手文本时为空字符串。仅改变状态的命令返回原生 handler 的结果文本。`finish_reason` 表示结束结果。原生命令身份与结果保留在 `command/run` 和 `command/done` 事件中。完成通知缺少匹配的命令生命周期事件时抛出 `SdkProtocolError`,不会返回不完整的结果。
|
||||
|
||||
内容块数组与字符串使用相同的命令路由。`session.run([{"type": "text", "text": "/compact"}])` 和 `session.run("/compact")` 都调用原生压缩并返回 `RunResult`。要绕过原生命令路由,调用底层 `client.session_prompt(session_id, blocks)` 并管理后续活动;运行时 skill(技能)识别仍然适用。
|
||||
|
||||
先选择支持图片输入的模型。通过现有内容块数组同时提交命令和图片,例如 `session.run([{"type": "text", "text": "/plan Analyze this image."}, {"type": "image", "data": image_base64, "mimeType": "image/png"}])`;`harness.run()` 接受相同输入。计划输入和目标创建、编辑接受图片;`/plan off`、目标控制、压缩和权限命令按[原生附件规则](../../packages/sdk/server/README.zh.md#command-input)拒绝图片。原生命令输入支持编码栅格图片,不支持一般文件上传或客户端提供的附件引用。
|
||||
|
||||
每次调用在发送前订阅,并在返回或抛出异常前释放订阅。`on_notification` 按传输顺序接收本次输入的会话树活动;区间可以包含重叠工作。调用可以重叠,不会消耗彼此的结果。原生拒绝返回 `finish_reason="error"`;路由、协议、传输和观察回调失败则抛出异常。格式错误的原生命令响应抛出 `SdkProtocolError`,保留校验原因。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的输入活动。
|
||||
|
||||
默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
|
||||
`/permission <preset>` 只修改接收命令的 Session。未知 preset 返回命令错误,不改变策略。后续输入与恢复保留成功的修改,其他 Session 和调用方的 `DeepSeekHarnessConfig` 保留自己的值。运行中的审批策略变化可以注入通知并触发下一模型步骤。见[权限示例](examples/README.zh.md#change-session-permissions)。
|
||||
|
||||
`/plan` 进入计划模式,`/plan off` 退出,`/plan <message>` 进入后通过原生 steer 提交去除首尾空白的正文。`run()` 等待提交的模型活动,包括原生审阅及获批后的后续执行;无需单独订阅或调用 `session/wait`。审阅期间保持 `on_user_question` 可用,见[计划示例](examples/README.zh.md#review-a-plan)。通过 `exit_plan_mode` 提交的计划位于审阅问题的 `detail` 中;`final_response` 包含最终助手文本。计划模式不修改权限设置。
|
||||
|
||||
`/goal` 显示当前目标。通过 `run()` 创建或恢复目标时,会跨越自动轮次等待,直到目标停止及最后的活动结束。完成返回 `completed`,暂停或清除返回 `aborted`,阻塞或失去激活状态报告失败。仅改变状态的控制命令不等待无关活动。恢复的活跃目标保持未激活状态,直到显式 resume。见[目标示例](examples/README.zh.md#run-a-goal)。
|
||||
|
||||
<a id="client-approvals"></a>
|
||||
## 客户端审批
|
||||
|
||||
完整 profile 的交互示例见 [`examples/manage_plugins.py`](examples/manage_plugins.py):它显式准备 Node/pnpm,在运行任务时通过已有 stdio 审批协议逐项询问权限,并在等待请求工作线程结束前关闭运行时。插件配置在运行时重启后生效。
|
||||
|
||||
此底层示例接收一次审批并拒绝。完整运行应使用专门处理请求的线程或循环:`next_request()` 会阻塞,请求处理器必须与 `session/wait` 或高层 `run()` 并行运行。仅在处理器就绪时,在 `HarnessClient.initialize()` 或 `DeepSeekHarnessConfig` 设置 `approval_requests=True`;省略时保留运行时应答链。决定只作用于单次操作;无效响应和断连会拒绝放行,取消后丢弃迟到响应。构造决定时,可用 `ApprovalRequestResult` 校验响应值。
|
||||
|
||||
```py
|
||||
@@ -106,17 +129,19 @@ 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 将不支持的斜杠输入保留在普通提示词路径上。
|
||||
|
||||
## 原生用户问答
|
||||
|
||||
向 `DeepSeekHarnessConfig`、`DeepSeekHarness` 或 `HarnessConfig` 传入 `on_user_question`,即可通过原生 SDK 协议回答内建的 `ask_user_question` 工具。回调接收 `UserQuestionRequest`(包含 `request_id`、`session_id` 和原始 `questions`)以及表示取消的 `threading.Event`。返回 `UserQuestionAnswer`,或形如 `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}` 的 JSON 对象。每个原问题 id 必须恰好出现一次。应用自行提供 UI 或模拟用户策略;SDK 不选择答案。
|
||||
向 `DeepSeekHarnessConfig`、`DeepSeekHarness` 或 `HarnessConfig` 传入 `on_user_question`,即可通过原生 SDK 协议回答内建的 `ask_user_question` 工具。回调接收 `UserQuestionRequest`(包含 `request_id`、`session_id` 和原始 `questions`)以及表示取消的 `threading.Event`。返回 `UserQuestionAnswer`,或形如 `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}` 的 JSON 对象。每个原问题 id 必须恰好出现一次。也可返回 `{ "cancelled": true }` 关闭问题而不作答;这会保留原生计划审查的关闭行为:结束轮次并保持计划模式。应用自行提供 UI 或模拟用户策略;SDK 不选择答案。
|
||||
|
||||
回调在独立线程执行,读取线程仍可处理取消和其他请求。回调可以并发执行,也可以再次调用客户端请求。取消 Event 置位时应释放回调拥有的工作。客户端关闭会通知所有待决回调,并给其工作线程总计 0.5 秒的 join 窗口;忽略取消的回调可能继续执行,但晚到答案不会发送到运行时或后续新进程。回调异常转为协议错误;未提供回调时保留原生回答方,或报告 `NO_PROVIDER`。
|
||||
|
||||
## 结果与通知
|
||||
|
||||
`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`;省略参数则无限等待。超时不取消运行时工作,也不关闭订阅,后续调用仍可接收通知。
|
||||
|
||||
`HarnessClient` 会在运行时进程的整个生命周期内保留已发现的 subagent 谱系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。
|
||||
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write python/sdk/examples/README.md
|
||||
/python-sdk-example:
|
||||
en: d9dfc996ea2ace40
|
||||
zh: 33de241bad6c4ee1
|
||||
en: c97ffc5ce0f7db05
|
||||
zh: bdd91202075498a3
|
||||
/python-sdk-example/run-the-minimal-agent:
|
||||
en: f0d785582fe72a91
|
||||
zh: ee563fb17c775ed3
|
||||
en: ac09bb6bc3ab76a0
|
||||
zh: e69d1e2bd796f3bf
|
||||
/python-sdk-example/compact-a-full-sdk-session:
|
||||
en: 6325ac9aaf9b9b67
|
||||
zh: 004a0ba5c2ff341b
|
||||
/python-sdk-example/change-session-permissions:
|
||||
en: 1fe5e06c6842a53b
|
||||
zh: ff0836d8cffaeef7
|
||||
/python-sdk-example/review-a-plan:
|
||||
en: 7a6d2d7929724240
|
||||
zh: 8a938c71a827f09b
|
||||
/python-sdk-example/run-a-goal:
|
||||
en: 59f62e70dc508bd6
|
||||
zh: bb10e2db241a2e92
|
||||
/python-sdk-example/manage-full-profile-plugins-with-approvals:
|
||||
en: 654011a3ca04a691
|
||||
zh: c33714237e9d5c64
|
||||
/python-sdk-example/add-plugins:
|
||||
en: 5e1e30dac13f6c9e
|
||||
zh: 0af98ebb75c88f43
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Runnable Python SDK example over the sole application launcher, `dsh --profile sdk-minimal`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, execution policy, and plugins.
|
||||
Runnable Python SDK examples over the sole application launcher, `dsh`. The Python client owns JSON-RPC stdio; the selected profile owns the agent composition, persistence, execution policy, and plugins.
|
||||
|
||||
## Run the minimal agent
|
||||
|
||||
@@ -25,10 +25,80 @@ 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.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 \
|
||||
--dsh-home /absolute/path/to/compact-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace
|
||||
```
|
||||
|
||||
The script prints `RunResult.final_response` for prompts and commands. An unsuccessful `finish_reason` 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 runs ordinary text on the same Session. Supply the preset explicitly:
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/permission.py \
|
||||
--dsh-home /absolute/path/to/permission-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
--preset read-only
|
||||
```
|
||||
|
||||
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 `run()` and keeps the Session notification subscription and question handler alive within that call until plan activity finishes. 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 \
|
||||
--dsh-home /absolute/path/to/plan-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"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 `run()` 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.
|
||||
|
||||
Piped answers use the parent process's stdin encoding and leave later lines for subsequent questions. UTF-16 and UTF-32 input requires an explicit little-endian or big-endian encoding. Truncated characters at EOF are reported as input errors.
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## Run a goal
|
||||
|
||||
The [goal example](goal.py) submits `/goal <objective>`, prints goal changes and assistant replies, and waits for the final reply after a terminal goal event. It uses the full SDK profile and workspace-write permissions.
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/goal.py \
|
||||
--dsh-home /absolute/path/to/goal-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"Calculate 17 * 19, verify the result, and complete the goal without changing files."
|
||||
```
|
||||
|
||||
`--timeout-seconds` 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. `run()` includes the automatic rounds and final reply.
|
||||
|
||||
## Manage full-profile plugins with approvals
|
||||
|
||||
[`manage_plugins.py`](manage_plugins.py) explicitly downloads the optional authoring runtime, then starts the full `sdk` profile with `workspace-write` permissions and stdio approvals. It displays each requested operation and requires `y` to grant it once; any other answer rejects it. The prepared Node/pnpm runs package operations even without system pnpm. Downloading this payload also prepares its Python libraries and Office skills.
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/manage_plugins.py \
|
||||
--dsh-home /absolute/path/to/example-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"Install and enable the bundle at /absolute/path/to/my-plugin-bundle."
|
||||
```
|
||||
|
||||
The current process keeps its original plugins. After a `restart-required` result, rerun the example with the same home to use the saved configuration. Removing a bundle still used by the running startup composition requires stopping that runtime and using `dsh plugin`.
|
||||
|
||||
## Add plugins
|
||||
|
||||
Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
基于唯一应用启动器 `dsh --profile sdk-minimal` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent(智能体)组合、持久化、执行策略与插件。
|
||||
基于唯一应用启动器 `dsh` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;所选 profile 负责 agent(智能体)组合、持久化、执行策略与插件。
|
||||
|
||||
## 运行极简 agent
|
||||
|
||||
@@ -25,10 +25,80 @@ 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 或容器中使用。
|
||||
|
||||
<a id="compact-a-full-sdk-session"></a>
|
||||
## 压缩完整 SDK Session
|
||||
|
||||
[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.run()` 输入,通过同一方法执行 `/compact`,随后使用保留的上下文继续提问。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home:
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/compact.py \
|
||||
--dsh-home /absolute/path/to/compact-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace
|
||||
```
|
||||
|
||||
脚本对提示词和命令均打印 `RunResult.final_response`。不成功的 `finish_reason` 会停止示例。完成和错误行为见[运行文本与命令](../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 中运行普通文本。请显式指定 preset:
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/permission.py \
|
||||
--dsh-home /absolute/path/to/permission-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
--preset read-only
|
||||
```
|
||||
|
||||
命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续输入使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[运行文本与命令](../README.zh.md#run-text-and-commands)。
|
||||
|
||||
<a id="review-a-plan"></a>
|
||||
## 审查计划
|
||||
|
||||
[计划示例](plan.py) 通过 `run()` 发送 `/plan <task>`,并在这次调用中保持 Session 通知订阅和问答处理器运行,直到计划活动结束。输入 `a` 批准、`k` 提供反馈、`d` 关闭;取消或作答前遇到 EOF 会关闭审查。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/plan.py \
|
||||
--dsh-home /absolute/path/to/plan-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"Plan the requested change and present it for review."
|
||||
```
|
||||
|
||||
原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `run()` 等待,并报告根轮次截断或失败;问答回调独立于协议读取线程。等待输入时也可以通过 Ctrl+C 结束示例。
|
||||
|
||||
管道答案使用父进程的 stdin 编码,后续行保留给后续问题。UTF-16 和 UTF-32 输入须明确指定小端或大端编码。EOF 处截断的字符会作为输入错误报告。
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## 运行目标
|
||||
|
||||
[目标示例](goal.py) 提交 `/goal <objective>`,打印目标变化和助手回复,并在终态目标事件后等待最后的回复。示例使用完整 SDK profile 和 workspace-write 权限。
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/goal.py \
|
||||
--dsh-home /absolute/path/to/goal-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"Calculate 17 * 19, verify the result, and complete the goal without changing files."
|
||||
```
|
||||
|
||||
`--timeout-seconds` 必须是有限正数,用于限制首次命令等待及之后的观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。`run()` 包含自动轮次及最后回复。
|
||||
|
||||
## 通过审批管理完整 profile 的插件
|
||||
|
||||
[`manage_plugins.py`](manage_plugins.py) 先显式下载可选创作运行时,再以 `workspace-write` 权限和 stdio 审批启动完整 `sdk` profile。它显示每次请求的操作,只有输入 `y` 才批准一次,其他回答均拒绝。准备好的 Node/pnpm 可在没有系统 pnpm 时执行包操作。下载该载荷也会准备其中的 Python 库和 Office skills。
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/manage_plugins.py \
|
||||
--dsh-home /absolute/path/to/example-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"Install and enable the bundle at /absolute/path/to/my-plugin-bundle."
|
||||
```
|
||||
|
||||
当前进程保留原有插件。收到 `restart-required` 后,使用相同 home 重新运行示例以加载已保存的配置。删除仍被当前启动组合使用的组合包,需要先停止该运行时,再使用 `dsh plugin`。
|
||||
|
||||
## 添加插件
|
||||
|
||||
对同一个显式 home 使用运行时 wheel 包提供的 `dsh` 命令,以进行持久 profile 变更:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compact a full SDK Session and continue with its retained context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, RunResult
|
||||
|
||||
|
||||
def display(result: RunResult) -> None:
|
||||
"""Print the input's result and stop on a reported failure."""
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Input ended with {result.finish_reason}: {result.final_response}")
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Create conversation history, compact it, and ask a follow-up question."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsh-home", required=True, type=Path)
|
||||
parser.add_argument("--workspace", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--session-id")
|
||||
parser.add_argument("--provider", default="deepseek-official")
|
||||
parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash"))
|
||||
parser.add_argument("--max-tokens", type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace = args.workspace.resolve()
|
||||
readme = workspace / "README.md"
|
||||
if not readme.is_file():
|
||||
parser.error("--workspace must contain README.md to provide conversation history")
|
||||
|
||||
with DeepSeekHarness(
|
||||
dsh_home=str(args.dsh_home.resolve()),
|
||||
cwd=str(workspace),
|
||||
profile="sdk",
|
||||
provider=args.provider,
|
||||
model=args.model,
|
||||
max_tokens=args.max_tokens,
|
||||
) as harness:
|
||||
session = harness.start_session(args.session_id)
|
||||
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")
|
||||
))
|
||||
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__":
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set a goal and observe automatic rounds through the final model reply."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, Notification
|
||||
from deepseek_harness.api import final_response
|
||||
|
||||
|
||||
def display(notification: Notification) -> dict | None:
|
||||
"""Print durable assistant output and return the observed root Session event."""
|
||||
if notification.method != "session.event":
|
||||
return None
|
||||
event = notification.payload["event"]
|
||||
if event["type"] == "assistant/message":
|
||||
text = final_response([event])
|
||||
if text:
|
||||
print(text, flush=True)
|
||||
elif event["type"] == "goal/change":
|
||||
print(f"Goal: {event['data']['operation']}", flush=True)
|
||||
elif event["type"] == "turn/end" and event["data"]["reason"]["kind"] != "completed":
|
||||
raise RuntimeError(f"Goal activity ended: {event['data']['reason']}")
|
||||
return event
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Observe goal state before waiting for the final activity interval."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsh-home", required=True, type=Path)
|
||||
parser.add_argument("--workspace", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash"))
|
||||
parser.add_argument("--timeout-seconds", type=float, default=300)
|
||||
parser.add_argument("objective", help="Objective for the native /goal command")
|
||||
args = parser.parse_args()
|
||||
if not args.objective.strip() or 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"},
|
||||
) as harness:
|
||||
session = harness.start_session()
|
||||
with ThreadPoolExecutor(max_workers=1) as worker:
|
||||
running = worker.submit(
|
||||
session.run, f"/goal {args.objective}",
|
||||
on_notification=lambda notice: display(notice) if notice.payload.get("sessionId") == session.id else None,
|
||||
)
|
||||
try:
|
||||
result = running.result(timeout=args.timeout_seconds)
|
||||
except FutureTimeoutError as error:
|
||||
raise TimeoutError("Goal observation timed out") from error
|
||||
finally:
|
||||
if not running.done():
|
||||
harness.close()
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Goal activity ended with {result.finish_reason}: {result.final_response}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a full SDK turn with explicit resource preparation and human permission decisions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import ApprovalRequestParams, DeepSeekHarness
|
||||
from deepseek_harness_runtime import download_primary_runtime
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Prepare package tools, answer stdio approvals, and print the final response."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("prompt")
|
||||
parser.add_argument("--dsh-home", type=Path, required=True)
|
||||
parser.add_argument("--dsh-bin", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, default=Path.cwd())
|
||||
args = parser.parse_args()
|
||||
if os.environ.get("DSH_PRIMARY_RUNTIME") is None:
|
||||
download_primary_runtime()
|
||||
with ThreadPoolExecutor(max_workers=2) as worker:
|
||||
harness = DeepSeekHarness(dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()),
|
||||
dsh_bin=None if args.dsh_bin is None else str(args.dsh_bin.resolve()),
|
||||
approval_requests=True,
|
||||
env={"DSH_PERMISSION_MODE": "workspace-write"})
|
||||
try:
|
||||
harness.start()
|
||||
run = worker.submit(harness.run, args.prompt)
|
||||
request = worker.submit(harness.client.next_request)
|
||||
while True:
|
||||
ready, _ = wait((run, request), return_when=FIRST_COMPLETED)
|
||||
if run in ready:
|
||||
print(run.result().final_response)
|
||||
break
|
||||
incoming = request.result()
|
||||
if incoming.method == "approval/request":
|
||||
approval = ApprovalRequestParams.model_validate(incoming.payload)
|
||||
print(f"Session {approval.sessionId}, tool {approval.toolName}: {approval.reason or ''}")
|
||||
choice = input("Allow this operation once? [y/N] ").strip().lower()
|
||||
harness.client.respond(incoming.id, {
|
||||
"outcome": "allowed-once" if choice == "y" else "rejected",
|
||||
})
|
||||
else:
|
||||
harness.client.respond_error(incoming.id, code=-32601, message="Unsupported client request")
|
||||
request = worker.submit(harness.client.next_request)
|
||||
finally:
|
||||
# Closing first releases the pending next_request before the executor joins its workers.
|
||||
harness.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -41,6 +41,8 @@ def main() -> None:
|
||||
profile=args.profile,
|
||||
) as harness:
|
||||
result = harness.run(args.prompt, session_id=args.session_id)
|
||||
if result.finish_reason not in (None, "completed"):
|
||||
raise RuntimeError(f"Input ended with {result.finish_reason}: {result.final_response}")
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Select a full SDK Session's permission preset and continue without resetting it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, RunResult
|
||||
|
||||
|
||||
def display(result: RunResult) -> None:
|
||||
"""Print the input's result and stop on a reported failure."""
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Input ended with {result.finish_reason}: {result.final_response}")
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""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())
|
||||
parser.add_argument("--preset", required=True)
|
||||
parser.add_argument("--provider", default="deepseek-official")
|
||||
parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash"))
|
||||
args = parser.parse_args()
|
||||
if not args.preset.strip():
|
||||
parser.error("--preset must be non-blank")
|
||||
|
||||
with DeepSeekHarness(
|
||||
dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()),
|
||||
profile="sdk", provider=args.provider, model=args.model,
|
||||
env={"DSH_PERMISSION_MODE": "workspace-write"},
|
||||
) as harness:
|
||||
session = harness.start_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']}")
|
||||
display(session.run("Summarize the current file and approval policy again. Do not use tools."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Submit a plan command, review its plan, and observe the Session's continuing work."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, Notification, UserQuestionRequest
|
||||
from deepseek_harness.api import final_response
|
||||
|
||||
|
||||
class _PlanReviews:
|
||||
"""Own cancellable readers with the parent's stdin encoding and join review callbacks before exit."""
|
||||
|
||||
# A buffered reader can consume later answers from a pipe before it exits.
|
||||
_READ_LINE = """import codecs, os, sys
|
||||
sys.stdin.reconfigure(encoding=sys.argv[1])
|
||||
if sys.stdin.isatty():
|
||||
try:
|
||||
text = input() + '\\n'
|
||||
except EOFError:
|
||||
text = ''
|
||||
else:
|
||||
decoder = codecs.getincrementaldecoder(sys.stdin.encoding or 'utf-8')()
|
||||
line = []
|
||||
while True:
|
||||
value = os.read(0, 1)
|
||||
decoded = decoder.decode(value, final=not value)
|
||||
if decoded:
|
||||
line.append(decoded)
|
||||
if not value or '\\n' in decoded:
|
||||
break
|
||||
text = ''.join(line)
|
||||
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, sys.stdin.encoding or "utf-8"], 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:
|
||||
"""Print assistant text and report an unsuccessful root Session turn."""
|
||||
if notification.method == "session.event":
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict) and event.get("type") == "assistant/message":
|
||||
text = final_response([event])
|
||||
if text:
|
||||
print(text)
|
||||
elif (isinstance(event, dict) and event.get("type") == "turn/end"
|
||||
and notification.payload.get("sessionId") == session_id):
|
||||
reason = event["data"]["reason"]
|
||||
if reason["kind"] != "completed":
|
||||
raise RuntimeError(f"Agent turn ended: {reason}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Keep the runtime and question handler alive until the plan activity finishes."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsh-home", required=True, type=Path)
|
||||
parser.add_argument("--workspace", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash"))
|
||||
parser.add_argument("task", help="Task to plan; implementation waits for your approval")
|
||||
args = parser.parse_args()
|
||||
if not args.task.strip():
|
||||
parser.error("task must be non-blank")
|
||||
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=reviews.answer,
|
||||
env={"DSH_PERMISSION_MODE": "workspace-write"},
|
||||
) as harness:
|
||||
session = harness.start_session()
|
||||
result = session.run(f"/plan {args.task}", on_notification=lambda notice: display(notice, session.id))
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Plan activity ended with {result.finish_reason}: {result.final_response}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from typing import Callable, Literal
|
||||
|
||||
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
|
||||
from .errors import SdkProtocolError
|
||||
from .models import JsonObject, Notification
|
||||
from .models import CommandExecution, JsonObject, Notification
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -17,6 +17,8 @@ class DeepSeekHarnessConfig:
|
||||
The runtime inherits the caller's environment by default, so existing
|
||||
DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to
|
||||
intentionally override or inject variables for a subprocess.
|
||||
These values are startup input, not a live Session state mirror; Session
|
||||
commands do not rewrite this object.
|
||||
"""
|
||||
|
||||
provider: str = "deepseek-official"
|
||||
@@ -41,13 +43,62 @@ class DeepSeekHarnessConfig:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunResult:
|
||||
"""Completed input with result text, outcome, root events and Session-tree notifications."""
|
||||
|
||||
session_id: str
|
||||
final_response: str
|
||||
"""Final assistant text (empty when absent), native state-only result text, or a failure diagnostic."""
|
||||
finish_reason: str | None
|
||||
events: list[JsonObject]
|
||||
notifications: list[Notification]
|
||||
|
||||
|
||||
class _RunActivity:
|
||||
"""Collect one input's root events and Session-tree notifications from its admission."""
|
||||
|
||||
def __init__(self, session_id: str, receipt_kind: Literal["prompt", "command"], receipt_id: str) -> None:
|
||||
self.session_id = session_id
|
||||
self.receipt_kind = receipt_kind
|
||||
self.receipt_id = receipt_id
|
||||
self.received = False
|
||||
self.command_done = False
|
||||
self.events: list[JsonObject] = []
|
||||
self.notifications: list[Notification] = []
|
||||
|
||||
def record(self, notification: Notification, on_notification: Callable[[Notification], None] | None = None) -> bool:
|
||||
if notification.method == "session.query.completed":
|
||||
return False
|
||||
if not self.received:
|
||||
if self.receipt_kind == "prompt":
|
||||
if not _is_inbox_receipt(notification, self.session_id, self.receipt_id):
|
||||
return False
|
||||
elif not _is_command_event(notification, self.session_id, self.receipt_id, "command/run"):
|
||||
return False
|
||||
self.received = True
|
||||
event = notification.payload.get("event") if (
|
||||
notification.method == "session.event" and notification.payload.get("sessionId") == self.session_id
|
||||
) else None
|
||||
if (self.receipt_kind == "command" and isinstance(event, dict) and event.get("type") == "command/done"
|
||||
and isinstance(event.get("data"), dict) and event["data"].get("commandId") == self.receipt_id):
|
||||
self.command_done = True
|
||||
self.notifications.append(notification)
|
||||
if on_notification is not None:
|
||||
on_notification(notification)
|
||||
if isinstance(event, dict):
|
||||
self.events.append(event)
|
||||
return True
|
||||
|
||||
def result(self, completed: JsonObject | None = None) -> RunResult:
|
||||
if completed is not None and not self.command_done:
|
||||
raise SdkProtocolError("Command completion is missing matching lifecycle events")
|
||||
return RunResult(
|
||||
session_id=self.session_id,
|
||||
final_response=completed["finalResponse"] if completed is not None else final_response(self.events),
|
||||
finish_reason=completed["finishReason"] if completed is not None else finish_reason(self.events),
|
||||
events=self.events, notifications=self.notifications,
|
||||
)
|
||||
|
||||
|
||||
class DeepSeekHarness:
|
||||
"""Reusable synchronous SDK for running DeepSeek Harness agent turns.
|
||||
|
||||
@@ -141,6 +192,7 @@ class DeepSeekHarness:
|
||||
session_id: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> RunResult:
|
||||
"""Run input to completion on a fresh or named Session and return its RunResult."""
|
||||
return self.start_session(session_id).run(input, on_notification=on_notification)
|
||||
|
||||
|
||||
@@ -165,37 +217,51 @@ class Session:
|
||||
*,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> RunResult:
|
||||
content_blocks = normalize_input(input)
|
||||
notifications: list[Notification] = []
|
||||
events: list[JsonObject] = []
|
||||
|
||||
def collect(notification: Notification) -> None:
|
||||
notifications.append(notification)
|
||||
if on_notification is not None:
|
||||
on_notification(notification)
|
||||
if (
|
||||
notification.method == "session.event"
|
||||
and notification.payload.get("sessionId") == self.id
|
||||
):
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
"""Run text or content blocks through native input routing.
|
||||
|
||||
Ordinary prompts finish at the next idle after their inbox receipt.
|
||||
State-only commands finish with their native outcome. Commands that
|
||||
submit model input wait for that activity; goal creation and resume wait
|
||||
through automatic rounds and the last reply after the goal stops.
|
||||
The first text block selects native commands. Matched commands require
|
||||
one text block with only encoded images beside it; native handlers decide
|
||||
which commands accept those images. Ordinary prompts retain block order
|
||||
and are queued through the existing prompt operation. Notifications retain
|
||||
wire order. Every result retains this Session id, its root events and
|
||||
Session-tree notifications, including overlapping work. Native refusals
|
||||
return finish_reason="error". Routing, transport and observer failures
|
||||
raise without rolling back work.
|
||||
"""
|
||||
self.harness.start()
|
||||
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,
|
||||
)
|
||||
|
||||
received = False
|
||||
response = self.harness.client._query(self.id, input)
|
||||
if isinstance(response, CommandExecution):
|
||||
activity = _RunActivity(self.id, "command", response.command_id)
|
||||
while True:
|
||||
notification = subscription.next()
|
||||
payload = notification.payload
|
||||
if notification.method == "session.query.completed" and payload.get("sessionId") == self.id:
|
||||
if not isinstance(payload.get("commandId"), str) or not payload["commandId"]:
|
||||
raise SdkProtocolError("Invalid session.query.completed command identity")
|
||||
if payload["commandId"] != response.command_id:
|
||||
continue
|
||||
if (not isinstance(payload.get("finalResponse"), str)
|
||||
or not isinstance(payload.get("finishReason"), str) or not payload["finishReason"]):
|
||||
raise SdkProtocolError("Invalid session.query.completed result")
|
||||
# Failure completion can precede handler settlement; its lifecycle precedes the RPC response.
|
||||
while not activity.command_done:
|
||||
try:
|
||||
queued = subscription.next(timeout_seconds=0)
|
||||
except TimeoutError:
|
||||
break
|
||||
activity.record(queued, on_notification)
|
||||
return activity.result(payload)
|
||||
activity.record(notification, on_notification)
|
||||
activity = _RunActivity(self.id, "prompt", response)
|
||||
while True:
|
||||
notification = subscription.next()
|
||||
if not received:
|
||||
if not _is_inbox_receipt(notification, self.id, message_id):
|
||||
continue
|
||||
received = True
|
||||
collect(notification)
|
||||
if not activity.record(notification, on_notification):
|
||||
continue
|
||||
if (
|
||||
notification.method == "session.status"
|
||||
and notification.payload.get("sessionId") == self.id
|
||||
@@ -203,13 +269,7 @@ class Session:
|
||||
):
|
||||
break
|
||||
|
||||
return RunResult(
|
||||
session_id=self.id,
|
||||
final_response=final_response(events),
|
||||
finish_reason=finish_reason(events),
|
||||
events=events,
|
||||
notifications=notifications,
|
||||
)
|
||||
return activity.result()
|
||||
|
||||
|
||||
def _is_inbox_receipt(notification: Notification, session_id: str, message_id: str) -> bool:
|
||||
@@ -225,10 +285,14 @@ def _is_inbox_receipt(notification: Notification, session_id: str, message_id: s
|
||||
)
|
||||
|
||||
|
||||
def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]:
|
||||
if isinstance(input, str):
|
||||
return [{"type": "text", "text": input}]
|
||||
return input
|
||||
def _is_command_event(notification: Notification, session_id: str, command_id: str, event_type: str) -> bool:
|
||||
if notification.method != "session.event" or notification.payload.get("sessionId") != session_id:
|
||||
return False
|
||||
event = notification.payload.get("event")
|
||||
if not isinstance(event, dict) or event.get("type") != event_type:
|
||||
return False
|
||||
data = event.get("data")
|
||||
return isinstance(data, dict) and data.get("commandId") == command_id
|
||||
|
||||
|
||||
def final_response(events: list[JsonObject]) -> str:
|
||||
|
||||
@@ -12,10 +12,10 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, TypeAlias, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from .errors import JsonRpcError, SdkProtocolError, TransportClosedError
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest
|
||||
from .models import CommandExecution, IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
NotificationFilter: TypeAlias = Callable[[Notification], bool]
|
||||
@@ -225,6 +225,21 @@ class HarnessClient:
|
||||
response_model=_EmptyResponse,
|
||||
)
|
||||
|
||||
def _query(
|
||||
self, session_id: str, input: str | list[JsonObject],
|
||||
) -> str | CommandExecution:
|
||||
"""Return a validated prompt receipt or command result for high-level run callers."""
|
||||
blocks = [{"type": "text", "text": input}] if isinstance(input, str) else input
|
||||
result = self._request_raw("session/query", {
|
||||
"sessionId": session_id, "contentBlocks": blocks,
|
||||
})
|
||||
if isinstance(result, dict):
|
||||
if result.get("kind") == "prompt" and isinstance(result.get("messageId"), str) and result["messageId"]:
|
||||
return result["messageId"]
|
||||
if result.get("kind") == "command":
|
||||
return _command_execution(result.get("execution"))
|
||||
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
|
||||
|
||||
def get_working_directory(self, session_id: str) -> str:
|
||||
"""Read the effective directory, recovering a missing directory to the Session origin."""
|
||||
response = self.request(
|
||||
@@ -675,8 +690,12 @@ class NotificationSubscription:
|
||||
self._closed = True
|
||||
self._client._unsubscribe_notifications(self._subscription_id)
|
||||
|
||||
def next(self) -> Notification:
|
||||
item = self._notifications.get()
|
||||
def next(self, timeout_seconds: float | None = None) -> Notification:
|
||||
"""Read one notification; an optional timeout stops only this local wait."""
|
||||
try:
|
||||
item = self._notifications.get(timeout=timeout_seconds)
|
||||
except queue.Empty as exc:
|
||||
raise TimeoutError("timed out waiting for an SDK notification") from exc
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
@@ -706,3 +725,11 @@ class _EmptyResponse(BaseModel):
|
||||
|
||||
def _int_or_none(value: object) -> int | None:
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def _command_execution(value: JsonValue) -> CommandExecution:
|
||||
"""Decode native command results and report malformed data as a protocol error."""
|
||||
try:
|
||||
return CommandExecution.model_validate(value)
|
||||
except ValidationError as error:
|
||||
raise SdkProtocolError("session/query returned an invalid command result") from error
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, TypeAlias
|
||||
from typing import Annotated, Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue
|
||||
from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue, field_validator
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"]
|
||||
@@ -32,6 +32,45 @@ class InitializeResponse(BaseModel):
|
||||
serverInfo: ServerInfo | None = None
|
||||
|
||||
|
||||
class CommandSuccessResult(BaseModel):
|
||||
"""Successful handler result, optionally linked to a Session event."""
|
||||
|
||||
kind: Literal["success"]
|
||||
text: str | None = None
|
||||
source_event_seq: int | None = Field(default=None, alias="sourceEventSeq", strict=True, ge=0, le=2**53 - 1)
|
||||
|
||||
@field_validator("text", "source_event_seq", mode="before")
|
||||
@classmethod
|
||||
def _reject_null(cls, value: object) -> object:
|
||||
if value is None:
|
||||
raise ValueError("optional command result fields must be omitted instead of null")
|
||||
return value
|
||||
|
||||
|
||||
class CommandErrorResult(BaseModel):
|
||||
"""A command handler's reported failure."""
|
||||
|
||||
kind: Literal["error"]
|
||||
text: str
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def _non_blank_text(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("command error text must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
CommandResult: TypeAlias = Annotated[CommandSuccessResult | CommandErrorResult, Field(discriminator="kind")]
|
||||
|
||||
|
||||
class CommandExecution(BaseModel):
|
||||
"""Settled command result and the id pairing its command/run and command/done events."""
|
||||
|
||||
command_id: str = Field(alias="commandId", min_length=1)
|
||||
result: CommandResult
|
||||
|
||||
|
||||
class ApprovalRequestParams(BaseModel):
|
||||
"""Server-to-client approval/request payload after explicit handshake opt-in."""
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""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_command_attachments_runtime.py",
|
||||
"test_goal_runtime.py",
|
||||
"test_goal_result_text_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())
|
||||
@@ -38,11 +38,11 @@ for line in sys.stdin:
|
||||
if method == "initialize":
|
||||
json.dump(msg.get("params"), open(os.environ["INIT_DUMP"], "w"))
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
params = msg.get("params") or {}
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session.event",
|
||||
@@ -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"
|
||||
@@ -233,10 +233,10 @@ for line in sys.stdin:
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "main", "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "running"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "idle"}}), flush=True)
|
||||
elif method == "shutdown":
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -272,10 +272,10 @@ for line in sys.stdin:
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
params = msg.get("params") or {}
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "turn/end", "data": {"turn": 1, "reason": {}}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True)
|
||||
elif method == "shutdown":
|
||||
@@ -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(
|
||||
@@ -346,10 +346,10 @@ for line in sys.stdin:
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "main", "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "running"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "main", "childSessionId": "child", "status": "ok", "stopReason": "completed"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "main", "status": "idle"}}), flush=True)
|
||||
@@ -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",
|
||||
@@ -388,11 +388,11 @@ for line in sys.stdin:
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
root = (msg.get("params") or {})["sessionId"]
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": root, "status": "running"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -448,13 +448,13 @@ for line in sys.stdin:
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
params = msg.get("params") or {}
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "other", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "wrong session"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": "other", "status": "idle"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "right session"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True)
|
||||
elif method == "shutdown":
|
||||
@@ -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
|
||||
@@ -485,11 +485,11 @@ for line in sys.stdin:
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
params = msg.get("params") or {}
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "running"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": "message-1"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "ok"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True)
|
||||
elif method == "shutdown":
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -517,14 +517,14 @@ for line in sys.stdin:
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif method == "session/prompt":
|
||||
elif method == "session/query":
|
||||
turn += 1
|
||||
params = msg.get("params") or {}
|
||||
session_id = params["sessionId"]
|
||||
message_id = f"message-{turn}"
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": message_id}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "running"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": message_id}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"kind": "prompt", "messageId": message_id}}), flush=True)
|
||||
if turn == 1:
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "first"}]}}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": session_id, "status": "idle"}}), flush=True)
|
||||
@@ -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"
|
||||
@@ -596,6 +596,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe
|
||||
assert notification.payload["sessionId"] == "other"
|
||||
|
||||
|
||||
def test_notification_timeout_keeps_subscription_available_for_later_events() -> None:
|
||||
client = HarnessClient()
|
||||
with client.subscribe_notifications() as subscription:
|
||||
with pytest.raises(TimeoutError, match="timed out waiting for an SDK notification"):
|
||||
subscription.next(timeout_seconds=0)
|
||||
client._handle_message({"jsonrpc": "2.0", "method": "tick", "params": {"value": 1}})
|
||||
assert subscription.next(timeout_seconds=0).payload == {"value": 1}
|
||||
|
||||
|
||||
def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None:
|
||||
client = HarnessClient()
|
||||
with client.subscribe_session_notifications("main") as first:
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Encoded command images retain desktop command rules through the shipped SDK profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deepseek_harness.models import CommandExecution
|
||||
from deepseek_harness import RunResult
|
||||
from deepseek_harness.errors import JsonRpcError
|
||||
from test_commands_runtime import (
|
||||
_Completion, _SESSION_ID, _WAIT_SECONDS, _command_pair, _native_result, _of_type,
|
||||
_read_events, _request_texts, _runtime, _wait_activity,
|
||||
)
|
||||
from test_goal_runtime import _command, _until
|
||||
from test_plan_runtime import _GUIDANCE, _effective_system
|
||||
|
||||
|
||||
_IMAGE_MODEL = "deepseek-flash"
|
||||
_RED = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
|
||||
_BLUE = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYPj/HwADAgH/5ncLrgAAAABJRU5ErkJggg=="
|
||||
|
||||
|
||||
def _image(name: str = "reference.png", data: str = _RED) -> dict:
|
||||
return {"type": "image", "data": data, "mimeType": "image/png", "name": name}
|
||||
|
||||
|
||||
def _request_images(request: dict) -> list[dict]:
|
||||
return [block for message in request["messages"] for block in message["content"]
|
||||
if block["type"] == "image"]
|
||||
|
||||
|
||||
def _assert_request_images(request: dict, *images: dict) -> None:
|
||||
assert _request_images(request) == [
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image["data"]}}
|
||||
for image in images
|
||||
]
|
||||
|
||||
|
||||
def _image_messages(events: list[dict]) -> list[dict]:
|
||||
return [event for event in _of_type(events, "user/message")
|
||||
if any(block["type"] == "image" for block in event["data"]["content"])]
|
||||
|
||||
|
||||
def _assert_durable_image(block: dict, image: dict) -> None:
|
||||
data = base64.b64decode(image["data"])
|
||||
assert block == {"type": "image", "attachment": {
|
||||
"attachmentId": "sha256:" + hashlib.sha256(data).hexdigest(),
|
||||
"mediaType": "image/png", "width": 1, "height": 1,
|
||||
"bytes": len(data), "name": image["name"],
|
||||
}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("line,body,image_first", [
|
||||
("/plan Describe this picture.", "Describe this picture.", False), ("/plan", "", True),
|
||||
])
|
||||
def test_plan_image_reaches_the_model_and_persists_as_a_reference(
|
||||
tmp_path: Path, line: str, body: str, image_first: bool,
|
||||
) -> None:
|
||||
picture = _image()
|
||||
text = {"type": "text", "text": line}
|
||||
content = [picture, text] if image_first else [text, picture]
|
||||
with _runtime(tmp_path, [_Completion("IMAGE_PLAN_READY")], model=_IMAGE_MODEL) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
execution = runtime.call(session.run, content)
|
||||
assert isinstance(execution, RunResult)
|
||||
assert execution.finish_reason == "completed" and execution.final_response == "IMAGE_PLAN_READY"
|
||||
_wait_activity(runtime, session)
|
||||
[request] = runtime.model.captured()
|
||||
_assert_request_images(request, picture)
|
||||
assert _GUIDANCE in _effective_system(request)
|
||||
assert line not in _request_texts(request)
|
||||
if body:
|
||||
assert body in _request_texts(request)
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution, "plan")
|
||||
assert [event["data"] for event in _of_type(events, "plan/mode")] == [{"active": True}]
|
||||
[message] = _image_messages(events)
|
||||
content = message["data"]["content"]
|
||||
_assert_durable_image(content[0], picture)
|
||||
assert content[1:] == ([{"type": "text", "text": body}] if body else [])
|
||||
digest = content[0]["attachment"]["attachmentId"].removeprefix("sha256:")
|
||||
stored = tmp_path / "home" / "attachments" / "v1" / "objects" / digest[:2] / digest
|
||||
assert stored.read_bytes() == base64.b64decode(picture["data"])
|
||||
|
||||
|
||||
def test_plan_off_compact_and_permission_reject_images_without_applying_the_command(tmp_path: Path) -> None:
|
||||
picture = _image()
|
||||
with _runtime(tmp_path, [], model=_IMAGE_MODEL, initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
_command(runtime, session, "/plan")
|
||||
executions = []
|
||||
for line in ["/plan off", "/compact", "/permission danger-full-access"]:
|
||||
execution = runtime.call(session.run, [{"type": "text", "text": line}, picture])
|
||||
assert isinstance(execution, RunResult)
|
||||
assert execution.finish_reason == "error"
|
||||
execution = _native_result(execution)
|
||||
assert "attachment" in execution.result.text.lower()
|
||||
executions.append((line.split()[0][1:], execution))
|
||||
assert "Plan mode off" in _command(runtime, session, "/plan off").result.text
|
||||
assert runtime.model.captured() == []
|
||||
events = _read_events(runtime.logs)
|
||||
for name, execution in executions:
|
||||
_command_pair(events, execution, name)
|
||||
start = next(event["seq"] for event in _of_type(events, "command/run")
|
||||
if event["data"]["commandId"] == execution.command_id)
|
||||
done = next(event["seq"] for event in _of_type(events, "command/done")
|
||||
if event["data"]["commandId"] == execution.command_id)
|
||||
assert events[start + 1:done] == []
|
||||
assert [event["data"] for event in _of_type(events, "plan/mode")] == [{"active": True}, {"active": False}]
|
||||
assert [event["data"] for event in _of_type(events, "permission/preset")] == [{"preset": "workspace-write"}]
|
||||
assert not _image_messages(events) and not _of_type(events, "turn/start")
|
||||
|
||||
|
||||
def test_goal_create_and_edit_images_reach_following_requests_as_durable_references(tmp_path: Path) -> None:
|
||||
original = _image("original.png")
|
||||
revised = _image("revised.png", _BLUE)
|
||||
first = _Completion("ORIGINAL_GOAL_IMAGE", blocked=True)
|
||||
second = _Completion("REVISED_GOAL_IMAGE", blocked=True)
|
||||
with _runtime(tmp_path, [first, second], model=_IMAGE_MODEL) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
created = runtime.call(runtime.harness.client._query, session.id, [original, {"type": "text", "text": "/goal Inspect the original picture."}])
|
||||
assert isinstance(created, CommandExecution) and created.result.kind == "success"
|
||||
assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
edited = runtime.call(runtime.harness.client._query, session.id, [revised, {"type": "text", "text": "/goal edit Inspect the revised picture."}])
|
||||
assert isinstance(edited, CommandExecution) and edited.result.kind == "success"
|
||||
_command(runtime, session, "/goal clear")
|
||||
assert not first.release.is_set()
|
||||
first.release.set()
|
||||
assert second.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
second.release.set()
|
||||
_wait_activity(runtime, session)
|
||||
before, after = runtime.model.captured()
|
||||
_assert_request_images(before, original)
|
||||
_assert_request_images(after, original, revised)
|
||||
assert "Reference attachments for the goal objective." in _request_texts(before)
|
||||
assert "Reference attachments for the goal objective." in _request_texts(after)
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, created, "goal")
|
||||
_command_pair(events, edited, "goal")
|
||||
changes = _of_type(events, "goal/change")
|
||||
assert [event["data"]["operation"] for event in changes] == ["create", "edit", "clear"]
|
||||
assert changes[1]["data"]["goal"]["objective"] == "Inspect the revised picture."
|
||||
messages = _image_messages(events)
|
||||
assert len(messages) == 2
|
||||
for message, picture in zip(messages, [original, revised]):
|
||||
_assert_durable_image(message["data"]["content"][0], picture)
|
||||
assert message["data"]["content"][1:] == [{
|
||||
"type": "text", "text": "Reference attachments for the goal objective.",
|
||||
}]
|
||||
|
||||
|
||||
def test_cleared_goal_waits_for_its_image_input_queued_during_compaction(tmp_path: Path) -> None:
|
||||
picture = _image()
|
||||
summary = _Completion("Retain the earlier task.", blocked=True)
|
||||
reply = _Completion("REFERENCE_INPUT_REPLY", blocked=True)
|
||||
with _runtime(tmp_path, [_Completion("EARLIER_REPLY"), summary, reply], model=_IMAGE_MODEL) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
runtime.call(session.run, "Retain this earlier task. " * 256)
|
||||
compacting = runtime.workers.submit(session.run, "/compact")
|
||||
assert summary.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as notices:
|
||||
running = runtime.workers.submit(session.run, [
|
||||
{"type": "text", "text": "/goal Inspect the reference."}, picture,
|
||||
])
|
||||
_until(notices, "goal/change", "create")
|
||||
cleared = session.run("/goal clear")
|
||||
assert cleared.finish_reason == "completed" and cleared.final_response == "Goal cleared."
|
||||
summary.release.set()
|
||||
assert compacting.result(timeout=_WAIT_SECONDS).finish_reason == "completed"
|
||||
assert reply.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
assert not running.done()
|
||||
_assert_request_images(runtime.model.captured()[-1], picture)
|
||||
reply.release.set()
|
||||
result = running.result(timeout=_WAIT_SECONDS)
|
||||
assert result.finish_reason == "aborted" and result.final_response == "REFERENCE_INPUT_REPLY"
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, result, "goal")
|
||||
assert [event["data"]["operation"] for event in _of_type(events, "goal/change")] == ["create", "clear"]
|
||||
|
||||
|
||||
def test_goal_controls_with_images_leave_the_active_request_and_goal_unchanged(tmp_path: Path) -> None:
|
||||
first = _Completion("CURRENT_GOAL_REQUEST_FINISHED", blocked=True)
|
||||
with _runtime(tmp_path, [first], model=_IMAGE_MODEL) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as notices:
|
||||
_command(runtime, session, "/goal Keep the current goal.")
|
||||
assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
rejected = []
|
||||
for line in ["/goal", "/goal pause", "/goal resume", "/goal clear", "/goal edit", "/goal Duplicate goal"]:
|
||||
execution = runtime.call(session.run, [{"type": "text", "text": line}, _image()])
|
||||
assert isinstance(execution, RunResult) and execution.finish_reason == "error"
|
||||
execution = _native_result(execution)
|
||||
rejected.append(execution)
|
||||
assert "Status: active" in _command(runtime, session, "/goal").result.text
|
||||
assert not first.release.is_set() and len(runtime.model.captured()) == 1
|
||||
_command(runtime, session, "/goal clear")
|
||||
first.release.set()
|
||||
_wait_activity(runtime, session)
|
||||
assert _until(notices, "turn/end")["reason"]["kind"] == "completed"
|
||||
assert len(runtime.model.captured()) == 1
|
||||
events = _read_events(runtime.logs)
|
||||
for execution in rejected:
|
||||
_command_pair(events, execution, "goal")
|
||||
assert [event["data"]["operation"] for event in _of_type(events, "goal/change")] == ["create", "clear"]
|
||||
assert not _image_messages(events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("image_first", [False, True])
|
||||
def test_prompt_images_queue_during_an_active_turn(tmp_path: Path, image_first: bool) -> None:
|
||||
picture = _image()
|
||||
text = {"type": "text", "text": "Inspect the image."}
|
||||
content = [picture, text] if image_first else [text, picture]
|
||||
first = _Completion("BEFORE_IMAGE", blocked=True)
|
||||
with _runtime(tmp_path, [first, _Completion("IMAGE_RECEIVED")], model=_IMAGE_MODEL) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
running = runtime.workers.submit(session.run, "Start without an image.")
|
||||
assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
accepted = threading.Event()
|
||||
following = runtime.workers.submit(
|
||||
session.run, content,
|
||||
on_notification=lambda _notification: accepted.set(),
|
||||
)
|
||||
assert accepted.wait(_WAIT_SECONDS), "image prompt did not reach its inbox receipt"
|
||||
assert not first.release.is_set() and not following.done()
|
||||
first.release.set()
|
||||
assert isinstance(running.result(timeout=_WAIT_SECONDS), RunResult)
|
||||
result = following.result(timeout=_WAIT_SECONDS)
|
||||
assert isinstance(result, RunResult) and result.final_response == "IMAGE_RECEIVED"
|
||||
before, after = runtime.model.captured()
|
||||
_assert_request_images(before)
|
||||
_assert_request_images(after, picture)
|
||||
events = _read_events(runtime.logs)
|
||||
[message] = _image_messages(events)
|
||||
recorded = message["data"]["content"]
|
||||
_assert_durable_image(next(block for block in recorded if block["type"] == "image"), picture)
|
||||
assert [block["type"] for block in recorded] == [block["type"] for block in content]
|
||||
assert [block for block in recorded if block["type"] == "text"] == [text]
|
||||
assert not _of_type(events, "command/run")
|
||||
assert [event["data"] for event in _of_type(events, "step/start")] == [
|
||||
{"turn": 1, "step": 1}, {"turn": 2, "step": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_top_level_run_accepts_an_image_without_text(tmp_path: Path) -> None:
|
||||
picture = _image()
|
||||
with _runtime(tmp_path, [_Completion("IMAGE_ONLY_REPLY")], model=_IMAGE_MODEL) as runtime:
|
||||
result = runtime.call(runtime.harness.run, [picture], session_id=_SESSION_ID)
|
||||
assert isinstance(result, RunResult) and result.final_response == "IMAGE_ONLY_REPLY"
|
||||
[request] = runtime.model.captured()
|
||||
_assert_request_images(request, picture)
|
||||
[message] = _image_messages(_read_events(runtime.logs))
|
||||
[block] = message["data"]["content"]
|
||||
_assert_durable_image(block, picture)
|
||||
|
||||
|
||||
def test_invalid_image_batch_cannot_enter_plan_mode_or_deliver_a_partial_prompt(tmp_path: Path) -> None:
|
||||
images = [_image(), _image("invalid.png", "not canonical base64")]
|
||||
with _runtime(tmp_path, [], model=_IMAGE_MODEL) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with pytest.raises(JsonRpcError) as ambiguous:
|
||||
runtime.call(session.run, [
|
||||
{"type": "text", "text": "/plan off"}, _image(),
|
||||
{"type": "text", "text": "This is separate text, not another command argument."},
|
||||
])
|
||||
assert "command" in str(ambiguous.value).lower()
|
||||
rejected = runtime.call(session.run, [{"type": "text", "text": "/plan Inspect the pictures."}, *images])
|
||||
assert isinstance(rejected, RunResult) and rejected.finish_reason == "error"
|
||||
rejected = _native_result(rejected)
|
||||
assert "base64" in rejected.result.text
|
||||
with pytest.raises(JsonRpcError, match="base64"):
|
||||
runtime.call(session.run, [*images, {"type": "text", "text": "Inspect the pictures."}])
|
||||
assert "already inactive" in _command(runtime, session, "/plan off").result.text
|
||||
assert runtime.model.captured() == []
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, rejected, "plan")
|
||||
assert len(_of_type(events, "command/run")) == 2
|
||||
assert not _of_type(events, "plan/mode")
|
||||
assert not _of_type(events, "agent/inbox/spliced")
|
||||
assert not _of_type(events, "turn/start")
|
||||
assert not _image_messages(events)
|
||||
@@ -0,0 +1,668 @@
|
||||
"""Exercise Session commands through Python and the real sdk profile with a local model.
|
||||
|
||||
The default carrier is the repository's built dsh CLI. Set
|
||||
DSH_COMMANDS_TEST_RUNTIME=installed to run these same cases against the imported
|
||||
SDK wheel and its bundled runtime, including from outside the repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing, contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from http.client import HTTPConnection, RemoteDisconnected
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, TypeVar
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from deepseek_harness.models import CommandExecution
|
||||
from deepseek_harness import DeepSeekHarness, Notification, RunResult, Session
|
||||
from deepseek_harness.client import UserQuestionHandler
|
||||
|
||||
|
||||
_WAIT_SECONDS = 30
|
||||
_SESSION_ID = "commands-runtime"
|
||||
_MODEL = "deepseek-v4-flash"
|
||||
_HISTORY_MARKER = "DSH_COMMANDS_OLD_USER_INPUT"
|
||||
_FIRST_REPLY = "SDK_COMPACT_READY"
|
||||
_CHECKPOINT = "## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."
|
||||
_CONTINUE_PROMPT = "DSH_COMMANDS_CONTINUE: answer using the retained checkpoint."
|
||||
_CONTINUE_REPLY = "SDK_COMPACT_OK"
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Completion:
|
||||
text: str
|
||||
blocked: bool = False
|
||||
tool_call: tuple[str, dict] | Callable[[dict], tuple[str, dict]] | None = None
|
||||
allow_disconnect: bool = False
|
||||
entered: threading.Event = field(default_factory=threading.Event)
|
||||
release: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.blocked:
|
||||
self.release.set()
|
||||
|
||||
|
||||
def _messages_response(text: str, model: str, ordinal: int, tool_call: tuple[str, dict] | None = None) -> bytes:
|
||||
if tool_call is None:
|
||||
block = {"type": "text", "text": ""}
|
||||
delta = {"type": "text_delta", "text": text}
|
||||
else:
|
||||
name, arguments = tool_call
|
||||
block = {"type": "tool_use", "id": f"commands-tool-{ordinal}", "name": name, "input": {}}
|
||||
delta = {"type": "input_json_delta", "partial_json": json.dumps(arguments)}
|
||||
events = [
|
||||
{"type": "message_start", "message": {
|
||||
"id": f"commands-response-{ordinal}", "type": "message", "role": "assistant",
|
||||
"model": model, "content": [], "stop_reason": None, "stop_sequence": None,
|
||||
"usage": {"input_tokens": 10000, "output_tokens": 0},
|
||||
}},
|
||||
{"type": "content_block_start", "index": 0, "content_block": block},
|
||||
{"type": "content_block_delta", "index": 0, "delta": delta},
|
||||
{"type": "content_block_stop", "index": 0},
|
||||
{"type": "message_delta", "delta": {"stop_reason": "end_turn" if tool_call is None else "tool_use", "stop_sequence": None},
|
||||
"usage": {"output_tokens": 32}},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
return "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events).encode()
|
||||
|
||||
|
||||
class _ModelServer:
|
||||
def __init__(self, completions: list[_Completion]) -> None:
|
||||
self.completions = completions
|
||||
self.requests: list[dict] = []
|
||||
self.failures: list[BaseException] = []
|
||||
self.lock = threading.Lock()
|
||||
owner = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def setup(self) -> None:
|
||||
self.request.settimeout(_WAIT_SECONDS)
|
||||
super().setup()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
payload = self.rfile.read(int(self.headers["Content-Length"]))
|
||||
if self.path == "/v1/files":
|
||||
# The model fixture accepts inline images through the provider's Files fallback.
|
||||
self.send_error(404, "Images are accepted inline")
|
||||
return
|
||||
assert self.path == "/v1/messages", f"unexpected model endpoint: {self.path}"
|
||||
body = json.loads(payload)
|
||||
assert isinstance(body, dict) and isinstance(body.get("model"), str)
|
||||
with owner.lock:
|
||||
ordinal = len(owner.requests)
|
||||
owner.requests.append(body)
|
||||
assert ordinal < len(owner.completions), "unexpected additional model request"
|
||||
completion = owner.completions[ordinal]
|
||||
completion.entered.set()
|
||||
# The test owns release. This larger bound only contains a failed test.
|
||||
assert completion.release.wait(_WAIT_SECONDS * 2), "model barrier was not released"
|
||||
tool_call = completion.tool_call(body) if callable(completion.tool_call) else completion.tool_call
|
||||
payload = _messages_response(completion.text, body["model"], ordinal, tool_call)
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||
if not completion.allow_disconnect:
|
||||
raise
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
pass
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
# server_close joins request handlers after their barriers are released.
|
||||
daemon_threads = False
|
||||
|
||||
def handle_error(self, _request: object, _client_address: object) -> None:
|
||||
error = sys.exc_info()[1]
|
||||
assert error is not None
|
||||
with owner.lock:
|
||||
owner.failures.append(error)
|
||||
|
||||
self.server = Server(("127.0.0.1", 0), Handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, name="commands-model", daemon=True)
|
||||
host, port = self.server.server_address
|
||||
self.url = f"http://{host}:{port}"
|
||||
|
||||
def __enter__(self) -> _ModelServer:
|
||||
try:
|
||||
self.thread.start()
|
||||
except BaseException:
|
||||
self.server.server_close()
|
||||
raise
|
||||
return self
|
||||
|
||||
def release_all(self) -> None:
|
||||
for completion in self.completions:
|
||||
completion.release.set()
|
||||
|
||||
def captured(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return list(self.requests)
|
||||
|
||||
def __exit__(self, exc_type: object, _exc: object, _tb: object) -> None:
|
||||
self.release_all()
|
||||
self.server.shutdown()
|
||||
try:
|
||||
self.server.server_close()
|
||||
finally:
|
||||
self.thread.join(_WAIT_SECONDS)
|
||||
assert not self.thread.is_alive(), "model listener did not stop"
|
||||
if exc_type is None:
|
||||
assert not self.failures, [repr(error) for error in self.failures]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error,allow_disconnect,accepted", [
|
||||
(ConnectionAbortedError(10053, "response connection aborted"), True, True),
|
||||
(ConnectionAbortedError(10053, "response connection aborted"), False, False),
|
||||
(OSError(5, "unrelated response write failure"), True, False),
|
||||
])
|
||||
def test_model_server_only_allows_expected_connection_aborts(
|
||||
monkeypatch: pytest.MonkeyPatch, error: OSError, allow_disconnect: bool, accepted: bool,
|
||||
) -> None:
|
||||
model = _ModelServer([_Completion("response", allow_disconnect=allow_disconnect)])
|
||||
body = {"model": _MODEL}
|
||||
|
||||
def fail_response(_handler: BaseHTTPRequestHandler) -> None:
|
||||
raise error
|
||||
|
||||
# Keep the real HTTP handler and teardown; only the response write fails deterministically.
|
||||
monkeypatch.setattr(model.server.RequestHandlerClass, "end_headers", fail_response)
|
||||
|
||||
def exchange() -> None:
|
||||
with model, closing(HTTPConnection(*model.server.server_address, timeout=_WAIT_SECONDS)) as connection:
|
||||
connection.request("POST", "/v1/messages", body=json.dumps(body))
|
||||
with pytest.raises(RemoteDisconnected):
|
||||
connection.getresponse()
|
||||
|
||||
if accepted:
|
||||
exchange()
|
||||
else:
|
||||
with pytest.raises(AssertionError, match=type(error).__name__):
|
||||
exchange()
|
||||
assert model.captured() == [body]
|
||||
assert model.failures == ([] if accepted else [error])
|
||||
assert not model.thread.is_alive()
|
||||
|
||||
|
||||
def _dsh_bin() -> str | None:
|
||||
mode = os.environ.get("DSH_COMMANDS_TEST_RUNTIME", "built")
|
||||
if mode == "installed":
|
||||
return None
|
||||
if mode != "built":
|
||||
raise ValueError("DSH_COMMANDS_TEST_RUNTIME must be built or installed")
|
||||
repo = Path(__file__).resolve().parents[3]
|
||||
node = shutil.which("node")
|
||||
binary = repo / "apps/cli/lib/bin.js"
|
||||
if node is None or not binary.is_file():
|
||||
pytest.skip("requires Node and built dsh CLI, or DSH_COMMANDS_TEST_RUNTIME=installed")
|
||||
return str(binary)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Runtime:
|
||||
harness: DeepSeekHarness
|
||||
model: _ModelServer
|
||||
workers: ThreadPoolExecutor
|
||||
logs: Path
|
||||
|
||||
def call(self, operation: Callable[..., _T], *args: object, **kwargs: object) -> _T:
|
||||
return self.workers.submit(operation, *args, **kwargs).result(timeout=_WAIT_SECONDS)
|
||||
|
||||
|
||||
class _WaitResult(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
def _wait_activity(runtime: _Runtime, session: Session) -> None:
|
||||
# Command completion does not own the model activity that its native handler starts.
|
||||
runtime.call(runtime.harness.client.request, "session/wait", {"sessionId": session.id}, response_model=_WaitResult)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _runtime(
|
||||
tmp_path: Path, completions: list[_Completion], *, capture_patch: Path | None = None,
|
||||
initial_permission: str = "danger-full-access",
|
||||
on_user_question: UserQuestionHandler | None = None,
|
||||
max_goal_rounds: int | None = None,
|
||||
model: str = _MODEL,
|
||||
) -> Iterator[_Runtime]:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
home = tmp_path / "home"
|
||||
logs = tmp_path / "sessions"
|
||||
patch = tmp_path / "commands.patch.yml"
|
||||
# Raw JSONL permits an independent read at the compaction durability boundary.
|
||||
patch.write_text(
|
||||
"- id: session-persistence-jsonl\n config:\n root: " + json.dumps(str(logs))
|
||||
+ "\n compression: none\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if max_goal_rounds is not None:
|
||||
with patch.open("a", encoding="utf-8") as extra:
|
||||
extra.write(f"- id: goal\n config:\n defaultMaxGoalRounds: {max_goal_rounds}\n")
|
||||
patches = (patch,) if capture_patch is None else (patch, capture_patch.resolve())
|
||||
dsh_bin = _dsh_bin()
|
||||
with _ModelServer(completions) as model_server, ThreadPoolExecutor(max_workers=2) as workers:
|
||||
harness = DeepSeekHarness(
|
||||
cwd=str(workspace), runtime_cwd=str(workspace), dsh_home=str(home),
|
||||
dsh_bin=dsh_bin, profile="sdk", patches=tuple(str(item) for item in patches), model=model,
|
||||
base_url=model_server.url, api_key="keyless-commands-runtime",
|
||||
initialize_timeout_seconds=60, request_timeout_seconds=20, shutdown_timeout_seconds=20,
|
||||
on_user_question=on_user_question,
|
||||
env={"DSH_PERMISSION_MODE": initial_permission,
|
||||
"DSH_TELEMETRY_DISABLED": "1", "DSH_TOOLS_MODE": "native"},
|
||||
)
|
||||
try:
|
||||
harness.start()
|
||||
yield _Runtime(harness, model_server, workers, logs)
|
||||
finally:
|
||||
# Also covers failed initialization: release HTTP, close the client, then join callers.
|
||||
model_server.release_all()
|
||||
harness.close()
|
||||
|
||||
|
||||
def _read_events(root: Path, *, live: bool = False, session_id: str = _SESSION_ID) -> list[dict]:
|
||||
paths = []
|
||||
for path in root.rglob("session*.jsonl"):
|
||||
with path.open(encoding="utf-8") as log:
|
||||
header = json.loads(log.readline())
|
||||
if header.get("id") == session_id:
|
||||
paths.append(path)
|
||||
assert len(paths) == 1, f"expected one Session log for {session_id}, found {paths}"
|
||||
content = paths[0].read_bytes()
|
||||
if not live:
|
||||
assert content.endswith(b"\n"), "closed Session log has an incomplete final row"
|
||||
# A live writer may append an unflushed suffix after the checkpoint we inspect.
|
||||
complete = content[:content.rfind(b"\n") + 1]
|
||||
records = [json.loads(line) for line in complete.splitlines()]
|
||||
assert records and records[0]["type"] == "session"
|
||||
assert records[0]["id"] == session_id
|
||||
events = records[1:]
|
||||
for seq, event in enumerate(events):
|
||||
assert isinstance(event, dict)
|
||||
assert event.setdefault("seq", seq) == seq
|
||||
return events
|
||||
|
||||
|
||||
def _of_type(events: list[dict], name: str) -> list[dict]:
|
||||
return [event for event in events if event["type"] == name]
|
||||
|
||||
|
||||
def _native_result(result: RunResult) -> CommandExecution:
|
||||
"""Read the native outcome retained in a high-level result's command lifecycle events."""
|
||||
started = next(event for event in result.events if event["type"] == "command/run")
|
||||
identity = started["data"]["commandId"]
|
||||
done = next(event["data"] for event in result.events
|
||||
if event["type"] == "command/done" and event["data"]["commandId"] == identity)
|
||||
return CommandExecution.model_validate({"commandId": identity, "result": {
|
||||
key: value for key, value in done.items() if key in ("kind", "text", "sourceEventSeq")
|
||||
}})
|
||||
|
||||
|
||||
def _command_pair(events: list[dict], execution: CommandExecution | RunResult, name: str = "compact") -> None:
|
||||
if isinstance(execution, RunResult):
|
||||
execution = _native_result(execution)
|
||||
runs = [event for event in _of_type(events, "command/run")
|
||||
if event["data"]["commandId"] == execution.command_id]
|
||||
done = [event for event in _of_type(events, "command/done")
|
||||
if event["data"]["commandId"] == execution.command_id]
|
||||
assert len(runs) == len(done) == 1
|
||||
assert runs[0]["data"]["name"] == name
|
||||
assert runs[0]["seq"] < done[0]["seq"]
|
||||
assert done[0]["data"]["kind"] == execution.result.kind
|
||||
assert done[0]["data"].get("text") == execution.result.text
|
||||
|
||||
|
||||
def _text_blocks(content: object) -> list[str]:
|
||||
if isinstance(content, str):
|
||||
return [content]
|
||||
assert isinstance(content, list)
|
||||
return [block["text"] for block in content if block.get("type") == "text"]
|
||||
|
||||
|
||||
def _request_texts(request: dict) -> list[str]:
|
||||
return [text for message in request["messages"] for text in _text_blocks(message["content"])]
|
||||
|
||||
|
||||
def _wait_command_done(next_notification: Callable[[], Notification]) -> dict:
|
||||
while True:
|
||||
notification = next_notification()
|
||||
if notification.method != "session.event" or notification.payload.get("sessionId") != _SESSION_ID:
|
||||
continue
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict) and event.get("type") == "command/done":
|
||||
return event["data"]
|
||||
|
||||
|
||||
def test_compact_without_history_returns_without_a_model_turn(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, []) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
observer = runtime.harness.client.subscribe_session_notifications(_SESSION_ID)
|
||||
try:
|
||||
execution = runtime.call(session.run, " \n/压缩\t ")
|
||||
done = runtime.call(_wait_command_done, observer.next)
|
||||
except BaseException:
|
||||
# Wake a timed-out observer before unregistering its otherwise blocking queue.
|
||||
runtime.harness.close()
|
||||
raise
|
||||
finally:
|
||||
observer.close()
|
||||
assert isinstance(execution, RunResult)
|
||||
execution = _native_result(execution)
|
||||
assert done["commandId"] == execution.command_id
|
||||
assert execution.result.kind == "success"
|
||||
assert execution.result.source_event_seq is None
|
||||
assert runtime.model.captured() == []
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution)
|
||||
assert not {event["type"] for event in events} & {
|
||||
"turn/start", "step/start", "request/header", "compaction/start", "user/message",
|
||||
}
|
||||
|
||||
|
||||
def test_compact_rejects_busy_agent_before_the_model_is_released(tmp_path: Path) -> None:
|
||||
response = _Completion("DSH_COMMANDS_BUSY_REPLY", blocked=True)
|
||||
with _runtime(tmp_path, [response]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
running = runtime.workers.submit(
|
||||
session.run, "Hold this request while compaction is attempted.",
|
||||
)
|
||||
assert response.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
assert not running.done() and not response.release.is_set()
|
||||
execution = runtime.call(session.run, "/compact")
|
||||
assert isinstance(execution, RunResult)
|
||||
execution = _native_result(execution)
|
||||
assert execution.result.kind == "error"
|
||||
assert not response.release.is_set()
|
||||
assert not running.done()
|
||||
assert len(runtime.model.captured()) == 1
|
||||
response.release.set()
|
||||
completed = running.result(timeout=_WAIT_SECONDS)
|
||||
assert isinstance(completed, RunResult)
|
||||
assert completed.final_response == response.text
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution)
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 1
|
||||
assert not _of_type(events, "compaction/start")
|
||||
|
||||
|
||||
def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None) -> Path:
|
||||
"""Run the complete scenario and retain its real Session log under the caller's directory."""
|
||||
summary_response = _Completion(_CHECKPOINT, blocked=True)
|
||||
with _runtime(
|
||||
tmp_path, [_Completion(_FIRST_REPLY), summary_response, _Completion(_CONTINUE_REPLY)],
|
||||
capture_patch=capture_patch,
|
||||
) as runtime:
|
||||
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
|
||||
first_result = runtime.call(session.run, prompt)
|
||||
assert isinstance(first_result, RunResult)
|
||||
assert first_result.final_response == _FIRST_REPLY
|
||||
|
||||
compacting = runtime.workers.submit(session.run, "/compact")
|
||||
assert summary_response.entered.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)
|
||||
assert isinstance(execution, RunResult)
|
||||
execution = _native_result(execution)
|
||||
assert execution.result.kind == "success"
|
||||
assert execution.result.source_event_seq is not None
|
||||
|
||||
# The native compactor promises this prefix before returning, unlike command/done.
|
||||
checkpointed = _read_events(runtime.logs, live=True)
|
||||
summaries = _of_type(checkpointed, "compaction/summary")
|
||||
starts = _of_type(checkpointed, "compaction/start")
|
||||
ends = _of_type(checkpointed, "compaction/end")
|
||||
assert len(starts) == len(summaries) == len(ends) == 1
|
||||
summary = summaries[0]
|
||||
assert summary["seq"] == execution.result.source_event_seq
|
||||
assert summary["data"]["llmStreamCall"] is True
|
||||
assert summary["data"]["provider"] == "deepseek-official"
|
||||
assert summary["data"]["model"] == _MODEL
|
||||
for event in [starts[0], summary, ends[0]]:
|
||||
assert event["data"]["sourceCommandId"] == execution.command_id
|
||||
assert event["data"]["compactionId"] == summary["data"]["compactionId"]
|
||||
assert starts[0]["data"]["turn"] is None and ends[0]["data"]["turn"] is None
|
||||
assert "error" not in ends[0]["data"]
|
||||
checkpoint = checkpointed[summary["seq"] + 1]
|
||||
assert checkpoint["type"] == "user/message"
|
||||
assert checkpoint["data"]["source"]["kind"] == "compact-checkpoint"
|
||||
assert checkpoint["surfaceOp"]["op"] == "replace"
|
||||
checkpoint_text = "\n".join(_text_blocks(checkpoint["data"]["content"]))
|
||||
assert _CHECKPOINT in checkpoint_text
|
||||
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
|
||||
|
||||
continued = runtime.call(session.run, _CONTINUE_PROMPT)
|
||||
assert isinstance(continued, RunResult)
|
||||
assert continued.final_response == _CONTINUE_REPLY
|
||||
first, compaction, following = runtime.model.captured()
|
||||
assert first["system"] == compaction["system"]
|
||||
assert first["tools"] == compaction["tools"]
|
||||
initial_texts = _request_texts(first)
|
||||
summary_texts = _request_texts(compaction)
|
||||
assert summary_texts[:len(initial_texts)] == initial_texts
|
||||
assert "You are now acting as a compaction engine" in summary_texts[-1]
|
||||
following_texts = _request_texts(following)
|
||||
following_text = "\n".join(following_texts)
|
||||
assert _CHECKPOINT in following_text and _CONTINUE_PROMPT in following_text
|
||||
assert _FIRST_REPLY in following_text
|
||||
assert _HISTORY_MARKER not in following_text
|
||||
assert "/compact" not in following_texts
|
||||
assert execution.result.text not in following_text
|
||||
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution)
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2
|
||||
assert len(_of_type(events, "step/start")) == len(_of_type(events, "step/end")) == 2
|
||||
assert _HISTORY_MARKER in json.dumps(events), "compaction erased the original stored input"
|
||||
return next(runtime.logs.rglob("session*.jsonl"))
|
||||
|
||||
|
||||
def test_compact_checkpoints_history_before_continuing_the_same_session(tmp_path: Path) -> None:
|
||||
run_compaction_scenario(tmp_path)
|
||||
|
||||
|
||||
def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_path: Path) -> None:
|
||||
prompts = ["/sdk-unregistered-command-fixture", "/compact extra"]
|
||||
replies = ["SDK_UNKNOWN_SLASH_REPLY", "SDK_COMPACT_ARGUMENTS_REPLY"]
|
||||
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):
|
||||
result = runtime.call(session.run, prompt)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == reply
|
||||
requests = runtime.model.captured()
|
||||
assert len(requests) == len(prompts)
|
||||
for prompt, request in zip(prompts, requests):
|
||||
assert prompt in _request_texts(request)
|
||||
events = _read_events(runtime.logs)
|
||||
assert not _of_type(events, "command/run")
|
||||
assert not _of_type(events, "compaction/start")
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2
|
||||
|
||||
|
||||
def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_path: Path) -> None:
|
||||
input_text = "/feedback DSH_FEEDBACK_TEXT_INPUT"
|
||||
following = "DSH_AFTER_PLAN_TEXT_INPUT"
|
||||
reply_before = "DSH_FEEDBACK_TEXT_REPLY"
|
||||
reply = "DSH_AFTER_PLAN_TEXT_REPLY"
|
||||
with _runtime(tmp_path, [_Completion(reply_before), _Completion(reply)]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
first = runtime.call(session.run, input_text)
|
||||
assert isinstance(first, RunResult)
|
||||
assert first.final_response == reply_before
|
||||
|
||||
result = runtime.call(session.run, following)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == reply
|
||||
initial, continued = runtime.model.captured()
|
||||
assert input_text in _request_texts(initial)
|
||||
assert following in _request_texts(continued)
|
||||
assert initial["tools"] == continued["tools"]
|
||||
events = _read_events(runtime.logs)
|
||||
assert not _of_type(events, "command/run")
|
||||
assert not any(event["type"].startswith("feedback/") for event in events)
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2
|
||||
|
||||
|
||||
_PERMISSION_FACTS = {"permission/preset", "sandbox/mode", "approval/policy"}
|
||||
_ASK_POLICY = "Approval prompts are enabled in this session"
|
||||
_NEVER_POLICY = "Approval prompts are disabled in this session"
|
||||
|
||||
|
||||
def _assert_request_permission(request: dict, sandbox: str, approval: str) -> None:
|
||||
contexts = [text for text in _request_texts(request) if text.startswith("Current runtime context.")]
|
||||
assert contexts, "model request has no runtime-context snapshot"
|
||||
assert f"Current DSH file policy: {sandbox}." in contexts[-1]
|
||||
assert (_ASK_POLICY if approval == "ask" else _NEVER_POLICY) in contexts[-1]
|
||||
|
||||
|
||||
def _permission_facts(events: list[dict]) -> list[dict]:
|
||||
return [{"type": event["type"], "data": event["data"]}
|
||||
for event in events if event["type"] in _PERMISSION_FACTS]
|
||||
|
||||
|
||||
def _permission_command(runtime: _Runtime, session: Session, preset: str) -> CommandExecution:
|
||||
result = runtime.call(session.run, f"/permission {preset}")
|
||||
assert isinstance(result, RunResult)
|
||||
return _native_result(result)
|
||||
|
||||
|
||||
def test_permission_changes_only_the_selected_session_and_retains_prompt_semantics(tmp_path: Path) -> None:
|
||||
completions = [_Completion(f"PERMISSION_REPLY_{index}") for index in range(9)]
|
||||
with _runtime(tmp_path, completions, initial_permission="workspace-write") as runtime:
|
||||
main = runtime.harness.start_session(_SESSION_ID)
|
||||
other = runtime.harness.start_session("permission-other")
|
||||
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.")]:
|
||||
assert isinstance(runtime.call(session.run, text), RunResult)
|
||||
|
||||
readonly = _permission_command(runtime, main, "read-only")
|
||||
invalid = _permission_command(runtime, main, "no-such-preset")
|
||||
repeated = _permission_command(runtime, main, "read-only")
|
||||
assert readonly.result.kind == repeated.result.kind == "success"
|
||||
assert invalid.result.kind == "error"
|
||||
assert "unknown preset" in invalid.result.text
|
||||
assert len(runtime.model.captured()) == 2, "idle permission commands woke the model"
|
||||
|
||||
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
|
||||
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"
|
||||
assert isinstance(runtime.call(main.run, "Switched-back policy; do not use tools."), RunResult)
|
||||
|
||||
for session in [other, runtime.harness.start_session("permission-new")]:
|
||||
assert isinstance(runtime.call(session.run, "Report your own policy; do not use tools."), RunResult)
|
||||
runtime.call(runtime.harness.client.session_prompt, main.id, [{"type": "text", "text": "/permission danger-full-access"}])
|
||||
_wait_activity(runtime, main)
|
||||
assert isinstance(runtime.call(main.run, "/permission"), RunResult)
|
||||
assert runtime.harness.config.env == initial_config
|
||||
|
||||
requests = runtime.model.captured()
|
||||
assert len(requests) == 9
|
||||
for index in [0, 1, 4, 5, 6, 7, 8]:
|
||||
_assert_request_permission(requests[index], "workspace-write", "ask")
|
||||
_assert_request_permission(requests[2], "read-only", "ask")
|
||||
_assert_request_permission(requests[3], "danger-full-access", "never")
|
||||
for index in [2, 3, 4]:
|
||||
assert requests[index]["system"] == requests[0]["system"]
|
||||
assert requests[index]["tools"] == requests[0]["tools"]
|
||||
assert "/permission danger-full-access" in _request_texts(requests[7])
|
||||
assert "/permission" in _request_texts(requests[8])
|
||||
|
||||
events = _read_events(runtime.logs)
|
||||
notices = [event["data"]["content"] for event in _of_type(events, "user/message")
|
||||
if event["data"]["source"]["kind"] == "user-approval"]
|
||||
assert len(notices) == 2
|
||||
for content, request in zip(notices, requests[3:5]):
|
||||
assert all(text in _request_texts(request) for text in _text_blocks(content))
|
||||
executions = [readonly, invalid, repeated, full, restored]
|
||||
assert len(_of_type(events, "command/run")) == len(executions)
|
||||
for execution in executions:
|
||||
_command_pair(events, execution, "permission")
|
||||
for execution in [invalid, repeated]:
|
||||
start = next(event["seq"] for event in _of_type(events, "command/run")
|
||||
if event["data"]["commandId"] == execution.command_id)
|
||||
done = next(event["seq"] for event in _of_type(events, "command/done")
|
||||
if event["data"]["commandId"] == execution.command_id)
|
||||
assert not _permission_facts(events[start + 1:done])
|
||||
for session_id in [_SESSION_ID, "permission-other", "permission-new"]:
|
||||
state = {event["type"]: event["data"] for event in _permission_facts(_read_events(runtime.logs, session_id=session_id))}
|
||||
assert state["permission/preset"] == {"preset": "workspace-write"}
|
||||
assert state["sandbox/mode"]["mode"] == "workspace-write"
|
||||
assert state["approval/policy"] == {"policy": "ask"}
|
||||
|
||||
|
||||
def test_running_permission_change_keeps_the_native_extra_step_and_notifications(tmp_path: Path) -> None:
|
||||
first = _Completion("PERMISSION_FIRST_DONE", blocked=True)
|
||||
second = _Completion("PERMISSION_SWITCH_SEEN")
|
||||
with _runtime(tmp_path, [first, second], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
observing = runtime.workers.submit(session.run, "Wait for the policy change, then finish without tools.")
|
||||
assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
execution = _permission_command(runtime, session, "danger-full-access")
|
||||
assert execution.result.kind == "success"
|
||||
assert not first.release.is_set()
|
||||
assert not observing.done()
|
||||
assert len(runtime.model.captured()) == 1
|
||||
first.release.set()
|
||||
result = observing.result(timeout=_WAIT_SECONDS)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == second.text
|
||||
requests = runtime.model.captured()
|
||||
assert len(requests) == 2
|
||||
_assert_request_permission(requests[0], "workspace-write", "ask")
|
||||
_assert_request_permission(requests[1], "danger-full-access", "never")
|
||||
assert requests[0]["system"] == requests[1]["system"]
|
||||
assert requests[0]["tools"] == requests[1]["tools"]
|
||||
assert runtime.harness.config.env["DSH_PERMISSION_MODE"] == "workspace-write"
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution, "permission")
|
||||
[notice] = [event["data"]["content"] for event in _of_type(events, "user/message")
|
||||
if event["data"]["source"]["kind"] == "user-approval"]
|
||||
for text in _text_blocks(notice):
|
||||
assert text not in _request_texts(requests[0])
|
||||
assert text in _request_texts(requests[1])
|
||||
assert [event["data"] for event in _of_type(events, "step/start")] == [
|
||||
{"turn": 1, "step": 1}, {"turn": 1, "step": 2},
|
||||
]
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 1
|
||||
|
||||
|
||||
def test_resumed_permission_survives_different_launch_defaults(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
execution = _permission_command(runtime, session, "read-only")
|
||||
assert execution.result.kind == "success"
|
||||
assert runtime.model.captured() == []
|
||||
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)
|
||||
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"
|
||||
assert _permission_facts(_read_events(resumed.logs)) == saved
|
||||
@@ -27,13 +27,13 @@ for line in sys.stdin:
|
||||
message = json.loads(line)
|
||||
if message["method"] == "initialize":
|
||||
result = {"serverInfo": {"name": "audit-fixture"}}
|
||||
elif message["method"] == "session/prompt":
|
||||
elif message["method"] == "session/query":
|
||||
session_id = message["params"]["sessionId"]
|
||||
notify("session.event", {"sessionId": session_id, "event": {
|
||||
"type": "agent/inbox/spliced", "seq": 0, "time": 0,
|
||||
"data": {"target": "next-turn", "start": 0, "inserted": [{"id": "accepted"}]}}})
|
||||
notify("session.status", {"sessionId": session_id, "status": "running"})
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": {"messageId": "accepted"}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": {"kind": "prompt", "messageId": "accepted"}}), flush=True)
|
||||
notify("session.event", {"sessionId": session_id, "event": {"type": "turn/start", "seq": 1, "time": 0, "data": {"turn": 0}}})
|
||||
notify("session.event", {"sessionId": session_id, "event": {"type": "assistant/message", "seq": 2, "time": 0,
|
||||
"data": {"message": {"role": "assistant", "content": [{"type": "text", "text": os.environ["MAIN_RESPONSE"]}]}}}})
|
||||
@@ -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")]
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Goal observation deadlines and runtime cleanup through the runnable example."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
import threading
|
||||
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", "activity", "complete", "error", "interrupt"])
|
||||
def test_goal_example_bounds_the_whole_run_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"]["contentBlocks"] == [{"type": "text", "text": "/goal Finish this task"}]
|
||||
if pending == "command":
|
||||
continue
|
||||
session = frame["params"]["sessionId"]
|
||||
for kind in ("command/run", "command/done"):
|
||||
send({"method": "session.event", "params": {"sessionId": session, "event": {
|
||||
"type": kind, "data": {"commandId": "goal-command"}}}})
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": "goal-command", "result": {"kind": "success", "text": "Goal set"}}}})
|
||||
if pending in ("activity", "interrupt"):
|
||||
continue
|
||||
if pending == "complete":
|
||||
send({"method": "session.event", "params": {"sessionId": session, "event": {
|
||||
"type": "goal/change", "data": {"operation": "complete"}}}})
|
||||
send({"method": "session.event", "params": {"sessionId": session, "event": {
|
||||
"type": "assistant/message", "data": {"message": {
|
||||
"content": [{"type": "text", "text": "Closing reply"}]}}}}})
|
||||
send({"method": "session.query.completed", "params": {"sessionId": session,
|
||||
"commandId": "goal-command", "finalResponse": "Closing reply" if pending == "complete" else "Goal stopped",
|
||||
"finishReason": "completed" if pending == "complete" else "error"}})
|
||||
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)
|
||||
if pending == "interrupt":
|
||||
entered = threading.Event()
|
||||
display = example["display"]
|
||||
def observe(notice):
|
||||
if notice.method == "session.event" and notice.payload["event"]["type"] == "command/done":
|
||||
entered.set()
|
||||
return display(notice)
|
||||
class InterruptedExecutor(ThreadPoolExecutor):
|
||||
def submit(self, *args, **kwargs):
|
||||
future = super().submit(*args, **kwargs)
|
||||
def interrupt(timeout=None):
|
||||
assert entered.wait(10), "command must be observed before interrupting its wait"
|
||||
raise KeyboardInterrupt
|
||||
monkeypatch.setattr(future, "result", interrupt)
|
||||
return future
|
||||
monkeypatch.setitem(example["main"].__globals__, "display", observe)
|
||||
monkeypatch.setitem(example["main"].__globals__, "ThreadPoolExecutor", InterruptedExecutor)
|
||||
timeout = "1" if pending in {"command", "activity"} else "10"
|
||||
monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"),
|
||||
"--workspace", str(tmp_path), "--timeout-seconds", timeout, "Finish this task"])
|
||||
with ThreadPoolExecutor(max_workers=1) as workers:
|
||||
execution = workers.submit(example["main"])
|
||||
try:
|
||||
if pending in {"command", "activity"}:
|
||||
with pytest.raises(TimeoutError, match="Goal observation timed out"):
|
||||
execution.result(timeout=30)
|
||||
elif pending == "interrupt":
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
execution.result(timeout=30)
|
||||
elif pending == "error":
|
||||
with pytest.raises(RuntimeError, match="Goal stopped"):
|
||||
execution.result(timeout=30)
|
||||
else:
|
||||
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()]
|
||||
assert [frame["method"] for frame in frames] == ["initialize", "session/query", "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
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Goal run results preserve empty assistant text through the shipped SDK profile."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from test_commands_runtime import _Completion, _runtime, _SESSION_ID
|
||||
from test_goal_runtime import _completion_steps
|
||||
|
||||
|
||||
def test_completed_goal_without_assistant_text_returns_empty_result(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, _completion_steps(_Completion("")),
|
||||
initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
result = runtime.call(session.run, "/goal Finish the task.")
|
||||
assert result.final_response == "" and result.finish_reason == "completed"
|
||||
assert any(event["type"] == "goal/change" and event["data"]["operation"] == "complete"
|
||||
for event in result.events)
|
||||
cleared = runtime.call(session.run, "/goal clear")
|
||||
assert cleared.final_response == "Goal cleared." and cleared.finish_reason == "completed"
|
||||
assert len(runtime.model.captured()) == 3
|
||||
|
||||
|
||||
def test_blocked_goal_without_assistant_text_returns_empty_result(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [_Completion("")], max_goal_rounds=1) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
result = runtime.call(session.run, "/goal Finish the task.")
|
||||
assert result.final_response == "" and result.finish_reason == "error"
|
||||
assert any(event["type"] == "goal/change" and event["data"]["goal"]["phase"] == "blocked"
|
||||
for event in result.events)
|
||||
assert len(runtime.model.captured()) == 1
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Native goal rounds and state changes through the Python SDK profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness.models import CommandExecution
|
||||
from deepseek_harness import RunResult, Session
|
||||
from deepseek_harness.client import NotificationSubscription
|
||||
from test_commands_runtime import (
|
||||
_Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _WaitResult, _assert_request_permission,
|
||||
_command_pair, _of_type, _permission_facts, _read_events,
|
||||
_request_texts, _runtime, _text_blocks, _wait_activity,
|
||||
)
|
||||
|
||||
|
||||
def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution:
|
||||
result = runtime.call(runtime.harness.client._query, session.id, text)
|
||||
assert isinstance(result, CommandExecution)
|
||||
assert result.result.kind == kind, result.result.text
|
||||
return result
|
||||
|
||||
|
||||
def _until(subscription: NotificationSubscription, event_type: str, operation: str | None = None) -> dict:
|
||||
deadline = time.monotonic() + _WAIT_SECONDS
|
||||
while True:
|
||||
notification = subscription.next(timeout_seconds=max(0, deadline - time.monotonic()))
|
||||
if notification.method != "session.event":
|
||||
continue
|
||||
event = notification.payload["event"]
|
||||
if event["type"] == event_type and (operation is None or event["data"].get("operation") == operation):
|
||||
return event["data"]
|
||||
|
||||
|
||||
def _complete_goal(request: dict) -> tuple[str, dict]:
|
||||
results = [block for message in request["messages"] for block in message["content"]
|
||||
if block["type"] == "tool_result"]
|
||||
current = json.loads("\n".join(_text_blocks(results[-1]["content"])))
|
||||
goal = current["goal"]
|
||||
assert goal["phase"] == "active" and current["activation"] == "armed"
|
||||
return "update_goal", {"goal_id": goal["id"], "revision": goal["revision"], "action": "complete"}
|
||||
|
||||
|
||||
def _completion_steps(final: _Completion | None = None) -> list[_Completion]:
|
||||
return [_Completion("", tool_call=("get_goal", {})), _Completion("", tool_call=_complete_goal),
|
||||
final if final is not None else _Completion("GOAL_DONE")]
|
||||
|
||||
|
||||
def test_goal_reads_and_invalid_controls_do_not_start_model_work(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, []) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
executions = [_command(runtime, session, "/目标")]
|
||||
for line in ["/goal edit", "/goal edit Missing goal", "/goal pause", "/goal resume"]:
|
||||
executions.append(_command(runtime, session, line, kind="error"))
|
||||
executions.append(_command(runtime, session, "/goal clear"))
|
||||
assert runtime.model.captured() == []
|
||||
events = _read_events(runtime.logs)
|
||||
assert not _of_type(events, "goal/change") and not _of_type(events, "turn/start")
|
||||
for execution in executions:
|
||||
_command_pair(events, execution, "goal")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("clear_before_reply", [False, True])
|
||||
def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(
|
||||
tmp_path: Path, clear_before_reply: bool,
|
||||
) -> None:
|
||||
final = _Completion("GOAL_DONE", blocked=True)
|
||||
with _runtime(tmp_path, [_Completion("PROGRESS"), *_completion_steps(final)],
|
||||
initial_permission="workspace-write", max_goal_rounds=3) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
running = runtime.workers.submit(session.run, "/目标 Verify the small change.")
|
||||
completed = _until(events, "goal/change", "complete")
|
||||
assert completed["goal"]["phase"] == "complete" and completed["roundsStarted"] == 2
|
||||
assert final.entered.wait(_WAIT_SECONDS)
|
||||
assert not running.done()
|
||||
if clear_before_reply:
|
||||
cleared = runtime.call(session.run, "/goal clear")
|
||||
assert cleared.final_response == "Goal cleared." and cleared.finish_reason == "completed"
|
||||
assert not running.done()
|
||||
final.release.set()
|
||||
result = running.result(timeout=_WAIT_SECONDS)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "GOAL_DONE" and result.finish_reason == "completed"
|
||||
operations = ["create", "complete", *(["clear"] if clear_before_reply else [])]
|
||||
assert [event["data"]["operation"] for event in result.events if event["type"] == "goal/change"] == operations
|
||||
_command_pair(_read_events(runtime.logs), result, "goal")
|
||||
|
||||
|
||||
def test_pause_aborts_then_edit_and_resume_use_revised_goal_without_changing_other_sessions(tmp_path: Path) -> None:
|
||||
first = _Completion("CANCELLED_RESPONSE", blocked=True, allow_disconnect=True)
|
||||
with _runtime(tmp_path, [first, *_completion_steps(), _Completion("RAW_GOAL_TEXT")]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal Original objective")
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/goal Duplicate objective", kind="error")
|
||||
_command(runtime, session, "/goal edit", kind="error")
|
||||
_command(runtime, session, "/goal resume", kind="error")
|
||||
_command(runtime, session, "/goal PAUSE")
|
||||
assert _until(events, "turn/end")["reason"]["kind"] == "aborted"
|
||||
assert not first.release.is_set()
|
||||
_command(runtime, session, "/goal edit Revised objective")
|
||||
assert len(runtime.model.captured()) == 1
|
||||
other = runtime.harness.start_session("goal-other")
|
||||
assert "No goal is currently set" in _command(runtime, other, "/goal").result.text
|
||||
_command(runtime, session, "/goal resume")
|
||||
_until(events, "goal/change", "complete")
|
||||
_wait_activity(runtime, session)
|
||||
assert 'Objective: "Revised objective"' in "\n".join(_request_texts(runtime.model.captured()[1]))
|
||||
_command(runtime, session, "/goal clear")
|
||||
_command(runtime, session, "/goal clear")
|
||||
runtime.call(runtime.harness.client.session_prompt, session.id, [{"type": "text", "text": "/goal Plain text objective"}])
|
||||
_wait_activity(runtime, session)
|
||||
assert "/goal Plain text objective" in _request_texts(runtime.model.captured()[-1])
|
||||
changes = _of_type(_read_events(runtime.logs), "goal/change")
|
||||
assert [e["data"]["operation"] for e in changes] == ["create", "pause", "edit", "resume", "complete", "clear"]
|
||||
assert not _of_type(_read_events(runtime.logs, session_id="goal-other"), "goal/change")
|
||||
|
||||
|
||||
def test_clear_keeps_current_request_and_stops_further_goal_rounds(tmp_path: Path) -> None:
|
||||
first = _Completion("CURRENT_ROUND_FINISHED", blocked=True)
|
||||
with _runtime(tmp_path, [first]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal Work until cleared")
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/goal clear")
|
||||
first.release.set()
|
||||
_wait_activity(runtime, session)
|
||||
assert _until(events, "turn/end")["reason"]["kind"] == "completed"
|
||||
assert len(runtime.model.captured()) == 1
|
||||
events = _read_events(runtime.logs)
|
||||
assert len(_of_type(events, "assistant/message")) == 1
|
||||
assert [e["data"]["operation"] for e in _of_type(events, "goal/change")] == ["create", "clear"]
|
||||
|
||||
|
||||
def test_round_limit_blocks_and_rejects_resume_without_another_request(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [_Completion("ONE"), _Completion("TWO")], max_goal_rounds=2) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal An unfinished objective")
|
||||
blocked = _until(events, "goal/change", "block")
|
||||
assert blocked["goal"]["blockedReason"]["code"] == "round-limit"
|
||||
assert blocked["roundsStarted"] == 2
|
||||
_command(runtime, session, "/goal resume", kind="error")
|
||||
_wait_activity(runtime, session)
|
||||
assert len(runtime.model.captured()) == 2
|
||||
|
||||
|
||||
def test_restored_active_goal_waits_for_explicit_resume(tmp_path: Path) -> None:
|
||||
first = _Completion("INTERRUPTED", blocked=True, allow_disconnect=True)
|
||||
with _runtime(tmp_path, [first], max_goal_rounds=3) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
runtime.call(runtime.harness.client.session_prompt, session.id, [{"type": "text", "text": "Start an ordinary task"}])
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/goal Persisted objective")
|
||||
runtime.harness.close()
|
||||
with _runtime(tmp_path, _completion_steps()) as runtime:
|
||||
session = runtime.call(runtime.harness.resume_session, _SESSION_ID)
|
||||
shown = _command(runtime, session, "/goal")
|
||||
assert "Status: active" in shown.result.text and "Activation: disarmed" in shown.result.text
|
||||
assert runtime.model.captured() == []
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal resume")
|
||||
_until(events, "goal/change", "complete")
|
||||
_wait_activity(runtime, session)
|
||||
assert 'Objective: "Persisted objective"\nRound: 1/3' in "\n".join(_request_texts(runtime.model.captured()[0]))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Run the plan example against the SDK profile with controlled model replies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deepseek_harness import DeepSeekHarness, Notification
|
||||
import test_commands_runtime as runtime_helpers
|
||||
from test_commands_runtime import _Completion, _ModelServer, _dsh_bin, _of_type, _read_events
|
||||
|
||||
|
||||
_EXAMPLE = Path(__file__).resolve().parents[1] / "examples/plan.py"
|
||||
_PLAN = "# Small change\n\n- Inspect the code.\n- Implement after approval."
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["max-tokens", "dismiss"])
|
||||
def test_plan_example_reports_truncation_and_accepts_explicit_dismissal(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], outcome: str,
|
||||
) -> None:
|
||||
example = runpy.run_path(str(_EXAMPLE))
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
logs = tmp_path / "sessions"
|
||||
patch = tmp_path / "persistence.yml"
|
||||
patch.write_text(
|
||||
"- id: session-persistence-jsonl\n config:\n root: " + json.dumps(str(logs))
|
||||
+ "\n compression: none\n", encoding="utf-8",
|
||||
)
|
||||
completion = (_Completion("PARTIAL_PLAN") if outcome == "max-tokens"
|
||||
else _Completion("", tool_call=("exit_plan_mode", {"plan": _PLAN})))
|
||||
if outcome == "max-tokens":
|
||||
response = runtime_helpers._messages_response
|
||||
|
||||
def truncated_response(*args: object) -> bytes:
|
||||
return response(*args).replace(b'"stop_reason": "end_turn"', b'"stop_reason": "max_tokens"')
|
||||
|
||||
monkeypatch.setattr(runtime_helpers, "_messages_response", truncated_response)
|
||||
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 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),),
|
||||
base_url=model.url, api_key="keyless-plan-example",
|
||||
initialize_timeout_seconds=30, request_timeout_seconds=20, shutdown_timeout_seconds=20,
|
||||
)
|
||||
|
||||
monkeypatch.setitem(example["main"].__globals__, "DeepSeekHarness", configured_harness)
|
||||
if outcome == "max-tokens":
|
||||
with pytest.raises(RuntimeError, match="max-tokens"):
|
||||
example["main"]()
|
||||
else:
|
||||
example["main"]()
|
||||
assert len(model.captured()) == 1
|
||||
[log] = logs.rglob("session*.jsonl")
|
||||
session_id = json.loads(log.read_text(encoding="utf-8").splitlines()[0])["id"]
|
||||
events = _read_events(logs, session_id=session_id)
|
||||
assert [event["data"]["reason"]["kind"] for event in _of_type(events, "turn/end")] == [
|
||||
"max-tokens" if outcome == "max-tokens" else "completed",
|
||||
]
|
||||
assert [event["data"] for event in _of_type(events, "plan/mode")] == [{"active": True}]
|
||||
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:
|
||||
display = runpy.run_path(str(_EXAMPLE))["display"]
|
||||
event = {"type": "turn/end", "data": {"turn": 1, "reason": {"kind": "aborted"}}}
|
||||
display(Notification(method="session.event", payload={"sessionId": "child", "event": event}), "root")
|
||||
with pytest.raises(RuntimeError, match="aborted"):
|
||||
display(Notification(method="session.event", payload={"sessionId": "root", "event": event}), "root")
|
||||
@@ -0,0 +1,323 @@
|
||||
"""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 concurrent.futures import ThreadPoolExecutor
|
||||
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()
|
||||
session_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 finish():
|
||||
# These cases isolate input handling from native plan turn outcomes.
|
||||
send({"method": "session.query.completed", "params": {
|
||||
"sessionId": session_id, "commandId": "plan-command",
|
||||
"finalResponse": "", "finishReason": "completed",
|
||||
}})
|
||||
|
||||
def cancel():
|
||||
while not stop.wait(0.01):
|
||||
if (root / "cancel").exists():
|
||||
send({"method": "user-question.cancelled", "params": {"requestId": "review-" + str(serial)}})
|
||||
finish()
|
||||
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:
|
||||
finish()
|
||||
elif method == "initialize":
|
||||
send({"id": frame["id"], "result": {"serverInfo": {"name": "plan-input-peer"}}})
|
||||
elif method == "session/query":
|
||||
session_id = frame["params"]["sessionId"]
|
||||
for event_type, data in [
|
||||
("command/run", {"commandId": "plan-command", "name": "plan"}),
|
||||
("command/done", {"commandId": "plan-command", "kind": "success"}),
|
||||
]:
|
||||
send({"method": "session.event", "params": {
|
||||
"sessionId": session_id, "event": {"type": event_type, "data": data},
|
||||
}})
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": "plan-command", "result": {"kind": "success", "text": "Plan mode entered"}}}})
|
||||
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, stdin_encoding = sys.argv[1:]
|
||||
if stdin_encoding:
|
||||
sys.stdin.reconfigure(encoding=stdin_encoding)
|
||||
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,
|
||||
stdin_encoding: str | None = None, python_args: tuple[str, ...] = (), env: dict[str, str] | None = None,
|
||||
) -> 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"
|
||||
if env is None:
|
||||
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, *python_args, str(wrapper), str(_EXAMPLE), str(peer), str(tmp_path), stdin_encoding or ""],
|
||||
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"] == []
|
||||
answers = tmp_path / "answers.jsonl"
|
||||
if answers.is_file():
|
||||
responses = [json.loads(line) for line in answers.read_text().splitlines()]
|
||||
assert not [response for response in responses if "error" in response], responses
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding,feedback", [
|
||||
("utf-8", "réviser Ċ encore"), ("latin-1", "réviser encore"),
|
||||
("utf-16-le", "réviser Ċ encore"), ("utf-16-be", "réviser Ċ encore"),
|
||||
("utf-32-le", "réviser Ċ encore"), ("utf-32-be", "réviser Ċ encore"),
|
||||
])
|
||||
def test_plan_input_preserves_reconfigured_stdin_feedback(tmp_path: Path, encoding: str, feedback: str) -> None:
|
||||
# U+010A contains byte 0x0a in UTF-16/32 without being a line ending.
|
||||
assert _run(tmp_path, f"k\r\n{feedback}\r\nd\r\n".encode(encoding), stdin_encoding=encoding) == [
|
||||
{"answers": [{"id": "plan-review", "selected": ["Keep planning"], "custom": feedback}]},
|
||||
{"cancelled": True},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["utf-8", "latin-1", "utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be"])
|
||||
@pytest.mark.parametrize("text", ["a", ""])
|
||||
def test_plan_input_preserves_reconfigured_stdin_at_eof(tmp_path: Path, encoding: str, text: str) -> None:
|
||||
assert _run(tmp_path, text.encode(encoding), stdin_encoding=encoding) == [
|
||||
{"answers": [{"id": "plan-review", "selected": ["Approve"]}]} if text else {"cancelled": True},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["utf-8", "utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be"])
|
||||
def test_plan_input_rejects_truncated_character_at_eof_and_reaps_reader(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, encoding: str,
|
||||
) -> None:
|
||||
owner_type = runpy.run_path(str(_EXAMPLE))["_PlanReviews"]
|
||||
encoded_input = tmp_path / "stdin.bin"
|
||||
encoded_input.write_bytes("é".encode(encoding)[:-1])
|
||||
readers = []
|
||||
popen = subprocess.Popen
|
||||
|
||||
def record_reader(*args, **kwargs):
|
||||
reader = popen(*args, **kwargs)
|
||||
readers.append(reader)
|
||||
return reader
|
||||
|
||||
with encoded_input.open(encoding=encoding) as terminal, monkeypatch.context() as patch:
|
||||
patch.setattr(sys, "stdin", terminal)
|
||||
patch.setattr(subprocess, "Popen", record_reader)
|
||||
with ThreadPoolExecutor(max_workers=1) as workers, owner_type() as owner:
|
||||
reading = workers.submit(owner._read_line, "Review: ", threading.Event())
|
||||
with pytest.raises(RuntimeError, match="UnicodeDecodeError"):
|
||||
reading.result(timeout=_WAIT_SECONDS)
|
||||
assert len(readers) == 1 and readers[0].poll() == 1
|
||||
assert owner._readers == []
|
||||
|
||||
|
||||
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"])
|
||||
@pytest.mark.parametrize("encoding", ["utf-8", "utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be"])
|
||||
def test_plan_input_cancellation_reaps_open_pipe_reader(tmp_path: Path, stage: str, encoding: str) -> None:
|
||||
prefix = "k\npartial feedback" if stage == "feedback" else "partial choice"
|
||||
data = prefix.encode(encoding) + "é".encode(encoding)[:-1]
|
||||
assert _run(tmp_path, data, stage=stage, action="protocol", stdin_encoding=encoding) == [{"cancelled": True}]
|
||||
assert not (tmp_path / "answers.jsonl").exists()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""POSIX terminal input and SIGINT cleanup for the plan example."""
|
||||
|
||||
import os
|
||||
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},
|
||||
]
|
||||
|
||||
|
||||
def test_plan_input_preserves_reconfigured_terminal_feedback(tmp_path: Path) -> None:
|
||||
assert _run(tmp_path, "k\nréviser\nd\n".encode("latin-1"), terminal=True, stdin_encoding="latin-1") == [
|
||||
{"answers": [{"id": "plan-review", "selected": ["Keep planning"], "custom": "réviser"}]},
|
||||
{"cancelled": True},
|
||||
]
|
||||
|
||||
|
||||
def test_plan_input_preserves_explicit_utf8_mode_with_ascii_child(tmp_path: Path) -> None:
|
||||
env = {**os.environ, "LC_ALL": "C", "PYTHONUTF8": "0", "PYTHONCOERCECLOCALE": "0"}
|
||||
env.pop("PYTHONIOENCODING", None)
|
||||
assert _run(tmp_path, "k\n继续检查\nd\n".encode("utf-8"), python_args=("-X", "utf8"), env=env) == [
|
||||
{"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}]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Native plan guidance, input timing and reviewed exit through the real SDK profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deepseek_harness.models import CommandExecution
|
||||
from deepseek_harness import RunResult, Session, UserQuestionRequest
|
||||
from test_commands_runtime import (
|
||||
_Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission,
|
||||
_command_pair, _native_result, _of_type, _permission_facts, _read_events,
|
||||
_request_texts, _runtime, _text_blocks, _wait_activity,
|
||||
)
|
||||
|
||||
_PLAN = "# Implement the change\n\n- Inspect existing code.\n- Make the smallest change.\n- Run focused tests."
|
||||
_PLAN_REVISED = "# Revised plan\n\n- Include a rollback procedure."
|
||||
_GUIDANCE = "You are in plan mode."
|
||||
|
||||
|
||||
def _command(runtime: _Runtime, session: Session, text: str) -> CommandExecution:
|
||||
result = runtime.call(runtime.harness.client._query, session.id, text)
|
||||
assert isinstance(result, CommandExecution)
|
||||
assert result.result.kind == "success", result.result.text
|
||||
return result
|
||||
|
||||
|
||||
def _effective_system(request: dict) -> str:
|
||||
system = "\n".join(_text_blocks(request.get("system", "")))
|
||||
for message in request["messages"]:
|
||||
if message["role"] == "system":
|
||||
system = "\n".join(_text_blocks(message["content"]))
|
||||
return system
|
||||
|
||||
|
||||
def _tool_names(request: dict) -> set[str]:
|
||||
return {tool["name"] for tool in request.get("tools", [])}
|
||||
|
||||
|
||||
def _review(plan: str = _PLAN) -> _Completion:
|
||||
return _Completion("", tool_call=("exit_plan_mode", {"plan": plan}))
|
||||
|
||||
|
||||
def _answer(request: UserQuestionRequest, choice: str, feedback: str | None = None) -> dict:
|
||||
[question] = request.questions
|
||||
assert question["intent"]["kind"] == "plan-review"
|
||||
item = {"id": question["id"], "selected": [choice]}
|
||||
if feedback is not None:
|
||||
item["custom"] = feedback
|
||||
return {"answers": [item]}
|
||||
|
||||
|
||||
def test_bare_plan_and_off_change_only_plan_state_without_a_model_request(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
on = _command(runtime, session, " \n/计划\t ")
|
||||
off = _command(runtime, session, "/plan off")
|
||||
repeated = _command(runtime, session, "/plan off")
|
||||
assert runtime.model.captured() == []
|
||||
assert "already inactive" in repeated.result.text
|
||||
assert runtime.harness.config.env["DSH_PERMISSION_MODE"] == "workspace-write"
|
||||
events = _read_events(runtime.logs)
|
||||
for execution in [on, off, repeated]:
|
||||
_command_pair(events, execution, "plan")
|
||||
assert [e["data"] for e in _of_type(events, "plan/mode")] == [{"active": True}, {"active": False}]
|
||||
assert not _of_type(events, "turn/start")
|
||||
assert [e["data"] for e in _permission_facts(events)] == [
|
||||
{"preset": "workspace-write"}, {"mode": "workspace-write"}, {"policy": "ask"},
|
||||
]
|
||||
|
||||
|
||||
def test_plan_body_uses_native_input_then_off_removes_guidance_but_keeps_exit_tool(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [_Completion("PLAN_READY"), _Completion("AFTER_OFF")], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
execution = _command(runtime, session, "/计划 Design this change without tools. ")
|
||||
_wait_activity(runtime, session)
|
||||
first = runtime.model.captured()[0]
|
||||
assert "Design this change without tools." in _request_texts(first)
|
||||
assert "/计划 Design this change without tools. " not in _request_texts(first)
|
||||
assert _GUIDANCE in _effective_system(first)
|
||||
assert "Do not edit or write files" in _effective_system(first)
|
||||
assert {"exit_plan_mode", "write", "edit"} <= _tool_names(first)
|
||||
_assert_request_permission(first, "workspace-write", "ask")
|
||||
_command(runtime, session, "/plan off")
|
||||
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)
|
||||
assert _tool_names(first) == _tool_names(second)
|
||||
assert "The user switched this session back to the default mode." in _request_texts(second)
|
||||
_assert_request_permission(second, "workspace-write", "ask")
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution, "plan")
|
||||
assert len(_of_type(events, "turn/start")) == 2
|
||||
assert len(_permission_facts(events)) == 3
|
||||
|
||||
|
||||
def test_plan_body_steers_the_next_step_of_a_running_turn(tmp_path: Path) -> None:
|
||||
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)
|
||||
observing = runtime.workers.submit(session.run, "Start this task without tools.")
|
||||
assert first.entered.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()
|
||||
result = observing.result(timeout=_WAIT_SECONDS)
|
||||
assert isinstance(result, RunResult) and result.final_response == "PLANNING_REPLY"
|
||||
a, b = runtime.model.captured()
|
||||
assert _GUIDANCE not in _effective_system(a)
|
||||
assert _GUIDANCE in _effective_system(b)
|
||||
assert "Design a smaller change." in _request_texts(b)
|
||||
_assert_request_permission(b, "workspace-write", "ask")
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution, "plan")
|
||||
assert [e["data"] for e in _of_type(events, "step/start")] == [{"turn": 1, "step": 1}, {"turn": 1, "step": 2}]
|
||||
assert len(_of_type(events, "turn/start")) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cancel", [False, True])
|
||||
def test_running_bare_selection_does_not_force_a_step_and_can_be_cancelled(tmp_path: Path, cancel: bool) -> None:
|
||||
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)
|
||||
observing = runtime.workers.submit(session.run, "Start without tools.")
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/plan")
|
||||
if cancel:
|
||||
cancelled = _command(runtime, session, "/plan off")
|
||||
assert "cancelled" in cancelled.result.text
|
||||
assert len(runtime.model.captured()) == 1
|
||||
first.release.set()
|
||||
assert isinstance(observing.result(timeout=_WAIT_SECONDS), RunResult)
|
||||
assert len(runtime.model.captured()) == 1
|
||||
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
|
||||
assert len(_of_type(events, "step/start")) == 2
|
||||
assert [e["data"] for e in _of_type(events, "plan/mode")] == ([] if cancel else [{"active": True}])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first_choice", ["Approve", "Keep planning", "dismiss"])
|
||||
def test_review_approval_feedback_and_dismissal_keep_native_model_behavior(tmp_path: Path, first_choice: str) -> None:
|
||||
seen: list[str] = []
|
||||
def answer(request: UserQuestionRequest, cancelled: threading.Event):
|
||||
assert not cancelled.is_set()
|
||||
seen.append(request.questions[0]["detail"])
|
||||
if len(seen) == 1:
|
||||
if first_choice == "dismiss":
|
||||
return {"cancelled": True}
|
||||
return _answer(request, first_choice, "Include rollback." if first_choice == "Keep planning" else None)
|
||||
return _answer(request, "Approve")
|
||||
completions = [_review()]
|
||||
if first_choice == "Keep planning":
|
||||
completions.append(_review(_PLAN_REVISED))
|
||||
if first_choice != "dismiss":
|
||||
completions.append(_Completion("AFTER_APPROVAL"))
|
||||
with _runtime(tmp_path, completions, initial_permission="workspace-write", on_user_question=answer) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
_command(runtime, session, "/plan Prepare a plan and present it for review.")
|
||||
_wait_activity(runtime, session)
|
||||
requests = runtime.model.captured()
|
||||
assert seen == ([_PLAN, _PLAN_REVISED] if first_choice == "Keep planning" else [_PLAN])
|
||||
assert _GUIDANCE in _effective_system(requests[0])
|
||||
if first_choice == "Keep planning":
|
||||
assert _GUIDANCE in _effective_system(requests[1])
|
||||
assert "Include rollback." in str(requests[1]["messages"])
|
||||
if first_choice != "dismiss":
|
||||
assert _GUIDANCE not in _effective_system(requests[-1])
|
||||
assert _tool_names(requests[0]) == _tool_names(requests[-1])
|
||||
else:
|
||||
assert len(requests) == 1, "dismissal started another model request"
|
||||
for request in requests:
|
||||
_assert_request_permission(request, "workspace-write", "ask")
|
||||
events = _read_events(runtime.logs)
|
||||
assert [e["data"]["active"] for e in _of_type(events, "plan/mode")] == ([True] if first_choice == "dismiss" else [True, False])
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 1
|
||||
assert len(_permission_facts(events)) == 3
|
||||
if first_choice == "dismiss":
|
||||
results = _of_type(events, "tool/result")
|
||||
assert len(results) == 1
|
||||
assert "The user dismissed the plan review" in str(results[0]["data"])
|
||||
|
||||
|
||||
def test_no_review_provider_keeps_plan_mode_and_reports_tool_failure(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [_review(), _Completion("REVIEW_UNAVAILABLE")], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
_command(runtime, session, "/plan Prepare a plan.")
|
||||
_wait_activity(runtime, session)
|
||||
first, second = runtime.model.captured()
|
||||
assert _GUIDANCE in _effective_system(first) and _GUIDANCE in _effective_system(second)
|
||||
assert "no user-questions answerer accepted the request" in str(second["messages"])
|
||||
_command(runtime, session, "/plan off")
|
||||
events = _read_events(runtime.logs)
|
||||
[result] = _of_type(events, "tool/result")
|
||||
assert result["data"]["message"]["isError"] is True
|
||||
assert result["data"]["error"]["code"] == "NO_PROVIDER"
|
||||
assert len(_permission_facts(events)) == 3
|
||||
|
||||
|
||||
def test_plan_state_survives_resume_without_changing_permissions(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
_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)
|
||||
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")
|
||||
events = _read_events(resumed.logs)
|
||||
assert [e["data"] for e in _of_type(events, "plan/mode")] == [{"active": True}]
|
||||
|
||||
|
||||
def test_top_level_plan_result_identifies_the_session_for_wait_continue_and_resume(tmp_path: Path) -> None:
|
||||
first = _Completion("PLAN_READY", blocked=True)
|
||||
with _runtime(tmp_path, [first, _Completion("CONTINUED_PLAN")], initial_permission="workspace-write") as runtime:
|
||||
running = runtime.workers.submit(runtime.harness.run, "/plan Design this change without tools.")
|
||||
assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
assert not running.done()
|
||||
first.release.set()
|
||||
execution = running.result(timeout=_WAIT_SECONDS)
|
||||
assert isinstance(execution, RunResult)
|
||||
assert execution.finish_reason == "completed" and execution.final_response == "PLAN_READY"
|
||||
session = runtime.harness.start_session(execution.session_id)
|
||||
continued = runtime.call(runtime.harness.run, "Continue this plan without tools.", session_id=execution.session_id)
|
||||
assert isinstance(continued, RunResult)
|
||||
assert continued.session_id == execution.session_id
|
||||
assert continued.final_response == "CONTINUED_PLAN"
|
||||
assert "Design this change without tools." in _request_texts(runtime.model.captured()[1])
|
||||
with _runtime(tmp_path, [_Completion("RESTORED_PLAN")], initial_permission="workspace-write") as resumed:
|
||||
session = resumed.call(resumed.harness.resume_session, execution.session_id)
|
||||
result = resumed.call(session.run, "Finish reviewing this plan without tools.")
|
||||
assert isinstance(result, RunResult) and result.session_id == execution.session_id
|
||||
assert result.final_response == "RESTORED_PLAN"
|
||||
[request] = resumed.model.captured()
|
||||
assert _GUIDANCE in _effective_system(request)
|
||||
assert "Design this change without tools." in _request_texts(request)
|
||||
assert "Continue this plan without tools." in _request_texts(request)
|
||||
events = _read_events(resumed.logs, session_id=execution.session_id)
|
||||
_command_pair(events, execution, "plan")
|
||||
assert len(_of_type(events, "turn/start")) == 3
|
||||
|
||||
|
||||
def test_plan_state_is_local_and_literal_prompts_do_not_toggle_it(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [_Completion("OTHER"), _Completion("PLANNING"), _Completion("RAW_INPUT")],
|
||||
initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
_command(runtime, session, "/plan")
|
||||
other = runtime.harness.start_session("plan-other")
|
||||
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)
|
||||
runtime.call(runtime.harness.client.session_prompt, session.id, [{"type": "text", "text": "/plan off"}])
|
||||
_wait_activity(runtime, session)
|
||||
first, second, third = runtime.model.captured()
|
||||
assert _GUIDANCE not in _effective_system(first)
|
||||
assert _GUIDANCE in _effective_system(second)
|
||||
assert _GUIDANCE in _effective_system(third)
|
||||
assert "/plan off" in _request_texts(third)
|
||||
assert not _of_type(_read_events(runtime.logs, session_id="plan-other"), "plan/mode")
|
||||
assert [e["data"] for e in _of_type(_read_events(runtime.logs), "plan/mode")] == [{"active": True}]
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Unified blocking input, native command results and independent run observation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from deepseek_harness.models import CommandExecution
|
||||
from deepseek_harness import 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 os
|
||||
import sys
|
||||
|
||||
serial = 0
|
||||
pending_commands = []
|
||||
pending_prompts = []
|
||||
pending_plans = []
|
||||
|
||||
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 input_text(frame):
|
||||
return "".join(block["text"] for block in frame["params"]["contentBlocks"] if block["type"] == "text")
|
||||
|
||||
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({"method": "session.query.completed", "params": {"sessionId": session, "commandId": identity,
|
||||
"finalResponse": "command completed", "finishReason": "completed"}})
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": identity, "result": {"kind": "success", "text": "command completed"}}}})
|
||||
|
||||
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 " + input_text(frame)}]}})
|
||||
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)
|
||||
with open(os.environ["REQUEST_LOG"], "a", encoding="utf-8") as log:
|
||||
log.write(line)
|
||||
method = frame["method"]
|
||||
if method == "initialize":
|
||||
send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}})
|
||||
elif method == "session/prompt":
|
||||
send({"id": frame["id"], "result": {"messageId": "literal-input"}})
|
||||
elif method == "session/query":
|
||||
serial += 1
|
||||
params = frame["params"]
|
||||
assert set(params) == {"sessionId", "contentBlocks"}
|
||||
text = input_text(frame)
|
||||
if text == "/plan hold":
|
||||
identity = "plan-" + str(serial)
|
||||
event(params["sessionId"], "command/run", {"commandId": identity})
|
||||
event(params["sessionId"], "command/done", {"commandId": identity, "kind": "success"})
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": identity, "result": {"kind": "success", "text": "Plan enabled"}}}})
|
||||
pending_plans.append((frame, identity))
|
||||
elif text == "hold-command":
|
||||
pending_commands.append((frame, serial))
|
||||
elif text in ("/compact", "/plan Describe this image."):
|
||||
command(frame, serial)
|
||||
elif text in ("missing-run", "missing-done", "missing-both", "unrelated-done", "invalid-done"):
|
||||
if text not in ("missing-run", "missing-both"):
|
||||
event(params["sessionId"], "command/run", {"commandId": "silent"})
|
||||
if text == "invalid-done":
|
||||
event(params["sessionId"], "command/done", None)
|
||||
elif text not in ("missing-done", "missing-both"):
|
||||
event(params["sessionId"], "command/done", {
|
||||
"commandId": "another-command" if text == "unrelated-done" else "silent", "kind": "success"})
|
||||
send({"method": "session.query.completed", "params": {"sessionId": params["sessionId"],
|
||||
"commandId": "silent", "finalResponse": "", "finishReason": "completed"}})
|
||||
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 == "custom-response":
|
||||
response = json.loads(os.environ["QUERY_RESPONSE"])
|
||||
execution = response.get("execution") if isinstance(response, dict) else None
|
||||
if isinstance(execution, dict) and isinstance(execution.get("result"), dict):
|
||||
event(params["sessionId"], "command/run", {"commandId": execution.get("commandId")})
|
||||
if os.environ.get("QUERY_LIFECYCLE") != "late-done":
|
||||
event(params["sessionId"], "command/done", {"commandId": execution.get("commandId"), **execution["result"]})
|
||||
send({"method": "session.query.completed", "params": {"sessionId": params["sessionId"],
|
||||
"commandId": execution.get("commandId"), "finalResponse": execution["result"].get("text", ""),
|
||||
"finishReason": "error" if execution["result"].get("kind") == "error" else "completed",
|
||||
**json.loads(os.environ.get("QUERY_COMPLETION", "{}"))}})
|
||||
if os.environ.get("QUERY_LIFECYCLE") == "late-done":
|
||||
event(params["sessionId"], "command/done", {"commandId": execution.get("commandId"), **execution["result"]})
|
||||
send({"id": frame["id"], "result": response})
|
||||
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, identity in pending_plans:
|
||||
finish(original)
|
||||
send({"method": "session.query.completed", "params": {"sessionId": original["params"]["sessionId"],
|
||||
"commandId": identity, "finalResponse": "Generated plan", "finishReason": "completed"}})
|
||||
pending_plans.clear()
|
||||
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)),
|
||||
env={"REQUEST_LOG": str(tmp_path / "requests.jsonl")},
|
||||
) 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, RunResult)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.session_id == "main"
|
||||
assert result.finish_reason == "completed"
|
||||
assert [notice.payload["event"]["type"] for notice in notifications if notice.method == "session.event"] == ["command/run", "command/done"]
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_top_level_command_results_identify_distinct_sessions_and_allow_continuing_one(
|
||||
sdk: DeepSeekHarness, tmp_path: Path,
|
||||
) -> None:
|
||||
first = sdk.run("/compact")
|
||||
second = sdk.run("/compact")
|
||||
assert isinstance(first, RunResult) and isinstance(second, RunResult)
|
||||
assert first.session_id and second.session_id and first.session_id != second.session_id
|
||||
continued = sdk.run("continue the first session", session_id=first.session_id)
|
||||
assert isinstance(continued, RunResult)
|
||||
assert continued.session_id == first.session_id
|
||||
sent = [json.loads(line) for line in (tmp_path / "requests.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
queries = [frame["params"] for frame in sent if frame["method"] == "session/query"]
|
||||
assert [query["sessionId"] for query in queries] == [first.session_id, second.session_id, first.session_id]
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", [
|
||||
{"kind": "success"},
|
||||
{"kind": "success", "text": "Compacted.", "sourceEventSeq": 0},
|
||||
{"kind": "success", "sourceEventSeq": 2**53 - 1},
|
||||
{"kind": "error", "text": "The agent is busy."},
|
||||
])
|
||||
def test_run_adds_session_identity_without_changing_command_results(peer: Path, tmp_path: Path, result: dict) -> None:
|
||||
execution = {"commandId": "custom-command", "result": result}
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
|
||||
_launch_args=(sys.executable, str(peer)),
|
||||
env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"), "QUERY_RESPONSE": json.dumps({
|
||||
"kind": "command", "execution": execution,
|
||||
})},
|
||||
) as sdk:
|
||||
returned = sdk.run("custom-response")
|
||||
assert isinstance(returned, RunResult)
|
||||
assert returned.session_id
|
||||
assert returned.final_response == result.get("text", "")
|
||||
assert returned.finish_reason == ("error" if result["kind"] == "error" else "completed")
|
||||
low_level = sdk.client._query(returned.session_id, "custom-response")
|
||||
assert isinstance(low_level, CommandExecution)
|
||||
assert low_level.model_dump(by_alias=True, exclude_none=True) == execution
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry,text", [
|
||||
("harness", "/plan Describe this image."),
|
||||
("session", "/plan Describe this image."),
|
||||
("harness", "Explain this image."),
|
||||
("session", "/unknown-slash"),
|
||||
("session", ""),
|
||||
])
|
||||
def test_run_forwards_content_blocks_with_the_routed_result_and_notifications(
|
||||
sdk: DeepSeekHarness, tmp_path: Path, entry: str, text: str,
|
||||
) -> None:
|
||||
blocks = [
|
||||
{"type": "text", "text": text},
|
||||
{"type": "image", "data": "AQ==", "mimeType": "image/png", "name": "diagram.png"},
|
||||
]
|
||||
notifications = []
|
||||
result = (
|
||||
sdk.run(blocks, session_id="main", on_notification=notifications.append)
|
||||
if entry == "harness" else sdk.start_session("main").run(
|
||||
blocks, on_notification=notifications.append,
|
||||
)
|
||||
)
|
||||
if text == "/plan Describe this image.":
|
||||
assert isinstance(result, RunResult)
|
||||
assert [notice.payload["event"]["type"] for notice in notifications if notice.method == "session.event"] == ["command/run", "command/done"]
|
||||
else:
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "answer for " + text
|
||||
assert result.notifications == notifications
|
||||
sent = [json.loads(line) for line in (tmp_path / "requests.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert sent[-1]["method"] == "session/query"
|
||||
assert sent[-1]["params"] == {
|
||||
"sessionId": "main", "contentBlocks": blocks,
|
||||
}
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_run_normalizes_strings_without_changing_their_text(sdk: DeepSeekHarness, tmp_path: Path) -> None:
|
||||
result = sdk.run(" preserve whitespace ", session_id="main")
|
||||
assert isinstance(result, RunResult)
|
||||
sent = [json.loads(line) for line in (tmp_path / "requests.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert sent[-1]["params"] == {
|
||||
"sessionId": "main", "contentBlocks": [{"type": "text", "text": " preserve whitespace "}],
|
||||
}
|
||||
|
||||
|
||||
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), RunResult)
|
||||
finally:
|
||||
release(sdk)
|
||||
result = first.result(timeout=5)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "answer for hold-prompt"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["missing-run", "missing-done", "missing-both", "unrelated-done", "invalid-done"])
|
||||
def test_command_result_rejects_incomplete_lifecycle(sdk: DeepSeekHarness, text: str) -> None:
|
||||
session = sdk.start_session("main")
|
||||
with pytest.raises(SdkProtocolError, match="lifecycle"):
|
||||
session.run(text)
|
||||
assert_released(sdk)
|
||||
assert session.run("after").final_response == "answer for after"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_failure_completion_can_precede_handler_settlement(peer: Path, tmp_path: Path) -> None:
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
|
||||
_launch_args=(sys.executable, str(peer)),
|
||||
env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"), "QUERY_LIFECYCLE": "late-done",
|
||||
"QUERY_RESPONSE": json.dumps({"kind": "command", "execution": {
|
||||
"commandId": "early-failure", "result": {"kind": "success"}}}),
|
||||
"QUERY_COMPLETION": json.dumps({"finalResponse": "goal service unloaded", "finishReason": "error"})},
|
||||
) as sdk:
|
||||
result = sdk.start_session("main").run("custom-response")
|
||||
assert result.final_response == "goal service unloaded" and result.finish_reason == "error"
|
||||
assert [event["type"] for event in result.events] == ["command/run", "command/done"]
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", SdkProtocolError)])
|
||||
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"), RunResult)
|
||||
|
||||
|
||||
@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"), RunResult)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response", [
|
||||
None, [], {"kind": "unknown"}, {"kind": "prompt"}, {"kind": "prompt", "messageId": ""},
|
||||
{"kind": "prompt", "messageId": 4},
|
||||
{"kind": "command", "execution": {"commandId": "c"}},
|
||||
{"kind": "command", "execution": {"commandId": "c", "result": {"kind": "success", "sourceEventSeq": True}}},
|
||||
])
|
||||
def test_run_rejects_invalid_routing_responses_as_protocol_errors(peer: Path, tmp_path: Path, response: object) -> None:
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
|
||||
_launch_args=(sys.executable, str(peer)),
|
||||
env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"), "QUERY_RESPONSE": json.dumps(response)},
|
||||
) as sdk:
|
||||
with pytest.raises(SdkProtocolError) as raised:
|
||||
sdk.start_session("main").run("custom-response")
|
||||
if isinstance(response, dict) and response.get("kind") == "command":
|
||||
assert isinstance(raised.value.__cause__, ValidationError)
|
||||
assert_released(sdk)
|
||||
assert isinstance(sdk.start_session("main").run("/compact"), RunResult)
|
||||
|
||||
|
||||
def test_plan_run_waits_for_model_output_after_the_command_receipt(sdk: DeepSeekHarness) -> None:
|
||||
admitted = threading.Event()
|
||||
session = sdk.start_session("plan")
|
||||
def observe(notice: Notification) -> None:
|
||||
if notice.method == "session.event" and notice.payload["event"]["type"] == "command/done":
|
||||
admitted.set()
|
||||
with ThreadPoolExecutor(max_workers=1) as workers:
|
||||
running = workers.submit(session.run, "/plan hold", on_notification=observe)
|
||||
try:
|
||||
assert admitted.wait(5)
|
||||
assert not running.done()
|
||||
finally:
|
||||
release(sdk)
|
||||
result = running.result(timeout=5)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "Generated plan" and result.finish_reason == "completed"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("completion", [{"commandId": None}, {"finalResponse": 1}, {"finishReason": None}, {"finishReason": ""}])
|
||||
def test_run_rejects_malformed_completion_and_releases_its_subscription(
|
||||
peer: Path, tmp_path: Path, completion: dict,
|
||||
) -> None:
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"),
|
||||
_launch_args=(sys.executable, str(peer)),
|
||||
env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"),
|
||||
"QUERY_COMPLETION": json.dumps(completion),
|
||||
"QUERY_RESPONSE": json.dumps({"kind": "command", "execution": {
|
||||
"commandId": "c", "result": {"kind": "success"}}})},
|
||||
) as sdk:
|
||||
with pytest.raises(SdkProtocolError):
|
||||
sdk.run("custom-response")
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("execution", [
|
||||
{},
|
||||
{"commandId": "command-1"},
|
||||
{"commandId": "", "result": {"kind": "success"}},
|
||||
{"commandId": "command-1", "result": {"kind": "unknown"}},
|
||||
{"commandId": "command-1", "result": {"kind": "error"}},
|
||||
{"commandId": "command-1", "result": {"kind": "error", "text": ""}},
|
||||
{"commandId": "command-1", "result": {"kind": "error", "text": " \t\n"}},
|
||||
{"commandId": "command-1", "result": {"kind": "success", "text": None}},
|
||||
{"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": None}},
|
||||
{"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": "4"}},
|
||||
{"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": True}},
|
||||
{"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": -1}},
|
||||
{"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": 2**53}},
|
||||
])
|
||||
def test_run_rejects_malformed_native_results(peer: Path, tmp_path: Path, execution: dict) -> None:
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"),
|
||||
_launch_args=(sys.executable, str(peer)),
|
||||
env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"), "QUERY_RESPONSE": json.dumps({
|
||||
"kind": "command", "execution": execution,
|
||||
})},
|
||||
) as sdk:
|
||||
with pytest.raises(SdkProtocolError) as raised:
|
||||
sdk.run("custom-response")
|
||||
assert isinstance(raised.value.__cause__, ValidationError)
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_low_level_prompt_keeps_slash_text_as_an_ordinary_prompt(sdk: DeepSeekHarness, tmp_path: Path) -> None:
|
||||
blocks = [{"type": "text", "text": "/compact"}]
|
||||
assert sdk.client.session_prompt("main", blocks) == "literal-input"
|
||||
sent = [json.loads(line) for line in (tmp_path / "requests.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert sent[-1]["method"] == "session/prompt"
|
||||
assert sent[-1]["params"] == {"sessionId": "main", "contentBlocks": blocks}
|
||||
@@ -102,6 +102,15 @@ def test_missing_callback_does_not_advertise_question_support(runtime: Path, tmp
|
||||
assert client.request("stats", {}, response_model=Response).capabilities is None
|
||||
|
||||
|
||||
def test_callback_can_dismiss_a_question_without_a_provider_error(runtime: Path, tmp_path: Path) -> None:
|
||||
with HarnessClient(config(lambda _request, _cancelled: {"cancelled": True}),
|
||||
_launch_args=(sys.executable, str(runtime))) as client:
|
||||
initialize(client, tmp_path)
|
||||
result = client.request("ask", {}, response_model=Response)
|
||||
assert result.answer == {"cancelled": True}
|
||||
assert result.error is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["cancel", "disconnect", "close"])
|
||||
def test_cancellation_stops_cooperative_host_work_and_suppresses_late_answer(runtime: Path, tmp_path: Path, action: str) -> None:
|
||||
entered = threading.Event()
|
||||
|
||||
Reference in new issue
Block a user