diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 6bcdbee5ac..35f5a33df1 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -337,7 +337,8 @@ jobs: smoke_python="$venv/bin/python" "$smoke_python" -m pip install \ "dist-python/$SDK_WHEEL" \ - "dist-python/$RUNTIME_WHEEL" + "dist-python/$RUNTIME_WHEEL" \ + 'pytest>=8.0' echo "python=$smoke_python" >> "$GITHUB_OUTPUT" - name: Install local SDK and runtime wheels into a clean venv (Windows) @@ -351,7 +352,7 @@ jobs: $venv = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))').Trim() python -m venv $venv $smokePython = Join-Path $venv 'Scripts\python.exe' - & $smokePython -m pip install "dist-python/$env:SDK_WHEEL" "dist-python/$env:RUNTIME_WHEEL" + & $smokePython -m pip install "dist-python/$env:SDK_WHEEL" "dist-python/$env:RUNTIME_WHEEL" 'pytest>=8.0' if ($LASTEXITCODE -ne 0) { throw "Wheel installation failed with exit code $LASTEXITCODE" } "python=$smokePython" >> $env:GITHUB_OUTPUT @@ -382,6 +383,19 @@ jobs: Pop-Location } + - name: Run installed-wheel native command tests (POSIX) + if: runner.os != 'Windows' + run: >- + "${{ steps.smoke-venv-posix.outputs.python }}" -I + "$GITHUB_WORKSPACE/python/sdk/tests/run_installed_commands.py" + + - name: Run installed-wheel native command tests (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + & "${{ steps.smoke-venv-windows.outputs.python }}" -I "$env:GITHUB_WORKSPACE\python\sdk\tests\run_installed_commands.py" + if ($LASTEXITCODE -ne 0) { throw "Installed-wheel native command tests failed with exit code $LASTEXITCODE" } + - name: Preflight installed-wheel real API test (POSIX) if: >- inputs.ci diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index b9171f2264..db2d757606 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -24,5 +24,5 @@ en: 450d54b6e9e38469 zh: f0dc06a08c073571 /get-started-with-the-python-sdk/understand-the-minimal-profile: - en: 8ed0bfd3cc9cc967 - zh: a52dab4cb3dd09ea + en: ea2a6521cff50517 + zh: 3769760a5e40af8d diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index e9457784cc..8250c9151d 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -176,7 +176,7 @@ Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSe The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while text file tools, settings, managed credentials, OTel telemetry, Web tools, subagents, and local instruction discovery are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads unaccepted log events with DeepSeek requests by default, at most `maxBytes` (8 MiB) per request; set `session-log-deepseek.enabled: false` in a profile patch to disable it. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container. -Image reading requires an image-capable route. Automatic compaction preserves the complete Session log; see the [bundle reference](../../../packages/bundle/sdk-minimal/README.md) for model and storage effects. SDK prompt text does not execute `/compact` or other slash commands. +Image reading requires an image-capable route. Automatic compaction preserves the complete Session log; see the [bundle reference](../../../packages/bundle/sdk-minimal/README.md) for model and storage effects. In this `sdk-minimal` composition, unsupported slash input remains ordinary prompt text. The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index bb5e15ae2f..d69fafcd60 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -176,7 +176,7 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle 该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文本文件工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传未接受的日志事件,每次请求最多 `maxBytes`(8 MiB);在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 -图片读取需要支持图片输入的路由。自动压缩保留完整 Session 日志;模型与存储影响见[组合包参考](../../../packages/bundle/sdk-minimal/README.zh.md)。SDK 提示词文本不会执行 `/compact` 或其他斜杠命令。 +图片读取需要支持图片输入的路由。自动压缩保留完整 Session 日志;模型与存储影响见[组合包参考](../../../packages/bundle/sdk-minimal/README.zh.md)。在这个 `sdk-minimal` 组合中,不支持的斜杠输入保持为普通提示词文本。 已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index e9e239eca1..1d77a4361c 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -21,11 +21,11 @@ en: 0e155c826651ff5e zh: 874fceaa9c13e3fa /deepseek-ai-dsh-sdk-client/use-this-package/run-text-and-native-commands: - en: 2c62b8477e74d7b5 - zh: 185fd7364e2ec71c + en: 224c38aa82b6821c + zh: e3ffb905696bbd8f /deepseek-ai-dsh-sdk-client/use-this-package/lower-level-control-with-harnessclient: - en: 5096b15f67e083b5 - zh: 746068458a423a2b + en: 7bf55ccd475e426f + zh: 9a49a68a94ef507c /deepseek-ai-dsh-sdk-client/use-this-package/native-user-questions: en: f5b9d6a7363bd9e5 zh: f53b3786d22a729d diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index c45923d6f1..2e1a6b1f27 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -70,7 +70,7 @@ else console.log(result.finalResponse) Strings return `CommandExecution` for a command or `RunResult` for ordinary prompt activity. Commands settle without requiring a turn or idle notification. Business refusals remain `result.kind === "error"`; routing, protocol, transport and observer failures reject. A command retains `commandId` and optional summary `sourceEventSeq` instead of selecting an assistant reply. To send slash text literally, use `session.run([{ type: 'text', text: '/compact' }])`. -Every call owns a subscription installed before sending and released before settlement. `onNotification` observes ordinary receipt-to-idle activity in wire order. Command notifications are delivered after its response, stopping at its own `command/done`. Concurrent calls keep their own results, so a running model turn can coexist with a command's native busy result. Timeout or observer failure does not roll back runtime work or authorize automatic retry; command completion does not end independent model activity. +Every call owns a subscription installed before sending and released before settlement. `onNotification` observes ordinary receipt-to-idle activity in wire order. Command callbacks replay Session-tree notifications from subscription through their own `command/done` after the command response; overlapping work can appear in that interval. Concurrent calls keep their own results, so a running model turn can coexist with a command's native busy result. Timeout or observer failure does not roll back runtime work or authorize automatic retry; command completion does not end independent model activity. Launch options supply initial values. A permission command changes only its target Session; later inputs and restoration retain that state, other Sessions keep theirs, and new Sessions use configured defaults. It does not update the options object or saved user defaults. An approval-policy change during a running turn can inject a notice and cause another step. See [permission presets](../../interaction/permission-presets/README.md) for native transition and restoration behavior. @@ -78,7 +78,7 @@ Launch options supply initial values. A permission command changes only its targ ### Lower-level control with HarnessClient -`client.query(sessionId, text, delivery, signal?)` waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` executes supported command names without prompt fallback; it requires a non-blank permission preset. A non-blank unknown preset reaches the native handler and returns `result.kind === "error"`. Subscribe before these calls when notifications are needed. An optional fourth `request()` argument supplies an `AbortSignal` that abandons only the local wait; runtime work can continue after timeout or local abandonment. +`client.query(sessionId, text, delivery)` waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` executes supported command names without prompt fallback; it requires a non-blank permission preset. A non-blank unknown preset reaches the native handler and returns `result.kind === "error"`. Subscribe before these calls when notifications are needed. A request timeout abandons only the local wait; runtime work can continue afterward. `/goal` uses the native goal handler and can start automatic rounds after its `CommandExecution`. Subscribe before sending; observe a terminal `goal/change` operation before using `session/wait` for the closing reply. Idle between rounds is not goal completion. See the [server semantics](../server/README.md) for pause, clear, restoration, and permission behavior. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index 759841842e..bbf75100cc 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -70,7 +70,7 @@ else console.log(result.finalResponse) 字符串被识别为命令时返回 `CommandExecution`,普通提示词活动返回 `RunResult`。命令完成不需要 turn 或 idle 通知。业务拒绝仍为 `result.kind === "error"`;路由、协议、传输和观察回调失败会拒绝 Promise。命令保留 `commandId` 及可选摘要 `sourceEventSeq`,不会选取助手回复作为结果。要原样发送斜杠文本,使用 `session.run([{ type: 'text', text: '/compact' }])`。 -每次调用都拥有自己的订阅,在发送前安装,在调用完成前释放。`onNotification` 按线上的顺序观察普通输入从入队回执到 idle 的活动。命令通知在其响应到达后交付,止于自身的 `command/done`。并发调用保留各自结果,所以运行中的模型轮次可以与命令的原生 busy 结果并存。超时或观察回调失败不回滚运行时工作,也不授权自动重试;命令完成不会结束独立的模型活动。 +每次调用都拥有自己的订阅,在发送前安装,在调用完成前释放。`onNotification` 按线上的顺序观察普通输入从入队回执到 idle 的活动。命令回调在命令响应到达后,按顺序交付从订阅时刻到自身 `command/done` 的 Session 树通知;该区间可能包含重叠工作的通知。并发调用保留各自结果,所以运行中的模型轮次可以与命令的原生 busy 结果并存。超时或观察回调失败不回滚运行时工作,也不授权自动重试;命令完成不会结束独立的模型活动。 启动选项提供初始值。权限命令只修改目标 Session;后续输入与恢复保留该状态,其他 Session 保留自己的状态,新 Session 使用配置默认值。命令不修改选项对象或已保存的用户默认值。运行中的审批策略变化可以注入通知并触发下一步骤。原生转换与恢复行为见[权限 preset](../../interaction/permission-presets/README.zh.md)。 @@ -78,7 +78,7 @@ else console.log(result.finalResponse) ### 用 HarnessClient 做低层控制 -`client.query(sessionId, text, delivery, signal?)` 等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 执行支持的命令名,不回退为提示词;permission preset 必须非空。非空的未知 preset 会进入原生 handler,返回 `result.kind === "error"`。需要通知时,应在调用前订阅。`request()` 的可选第四个参数接受 `AbortSignal`,仅放弃本地等待;超时或本地放弃后,运行时工作仍可继续。 +`client.query(sessionId, text, delivery)` 等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 执行支持的命令名,不回退为提示词;permission preset 必须非空。非空的未知 preset 会进入原生 handler,返回 `result.kind === "error"`。需要通知时,应在调用前订阅。请求超时仅放弃本地等待,运行时工作仍可继续。 `/goal` 使用原生目标 handler,可在 `CommandExecution` 返回后启动自动轮次。发送前应订阅;先观察终态 `goal/change` 操作,再用 `session/wait` 等待收尾回复。轮次之间的 idle 不代表目标完成。暂停、清除、恢复和权限行为见[服务端语义](../server/README.zh.md)。 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 3fad011967..701c2b7642 100644 --- a/packages/sdk/client/src/api.ts +++ b/packages/sdk/client/src/api.ts @@ -177,7 +177,11 @@ export interface RunOptions { sessionId?: string /** Text prompt delivery; content-block arrays require queue delivery. */ delivery?: 'queue' | 'steer' - /** Observer invoked with this input's notifications in wire order; command notifications follow its response. */ + /** + * Observe prompt activity from receipt through idle. Command callbacks replay + * Session-tree notifications from subscription through their own command/done + * after the response; either interval can include overlapping work. + */ onNotification?: (notification: HarnessNotification) => void } diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index 085120c26c..b4be39d8f5 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -379,17 +379,15 @@ export class HarnessClient { * @param sessionId - target Session; an unknown id creates it. * @param text - complete input text, without client-side command interpretation. * @param delivery - ordinary prompt delivery; commands retain their own admission rules. - * @param signal - abandons this client's wait without cancelling runtime work. * @returns the prompt receipt or native command execution. */ async query( sessionId: string, text: string, delivery: SessionQueryParams['delivery'], - signal?: AbortSignal, ): Promise { const params: SessionQueryParams = { sessionId: SessionId(sessionId), text, delivery } - const value = await this.request('session/query', params, undefined, signal) + const value = await this.request('session/query', params) if (isRecord(value)) { if (value.kind === 'prompt' && typeof value.messageId === 'string' && value.messageId.length > 0) { return { kind: 'prompt', messageId: value.messageId } @@ -442,13 +440,11 @@ export class HarnessClient { * @param method - the wire method name. * @param params - the params object; omitted params send `{}`. * @param timeoutMs - per-call override of {@link HarnessClientOptions.requestTimeoutMs}. - * @param signal - abandon only this local request wait; no cancellation is sent to the runtime. * @returns the raw result; rejects with {@link JsonRpcResponseError} on a * protocol error response, {@link RequestTimeoutError} on timeout, and * {@link TransportClosedError} when the runtime is gone. */ - async request(method: string, params?: object, timeoutMs?: number, signal?: AbortSignal): Promise { - signal?.throwIfAborted() + async request(method: string, params?: object, timeoutMs?: number): Promise { this.start() // A dead runtime cannot answer; fail with process context instead of // writing into a destroyed pipe and hanging until the timeout. @@ -461,7 +457,7 @@ export class HarnessClient { if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running') const timeout = timeoutMs ?? this.runtime.requestTimeoutMs try { - if (timeout === undefined) return await transport.request(method, params ?? {}, signal) + if (timeout === undefined) return await transport.request(method, params ?? {}) // The abort signal makes the timeout an abandonment: the transport drops // its pending entry, so repeated bounded requests against a hung method // retain no per-call state (the server-side work still runs to close). @@ -471,13 +467,12 @@ export class HarnessClient { abandon.abort(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for ${this.runtime.description}${stderr}`)) }, timeout) try { - return await transport.request(method, params ?? {}, signal === undefined - ? abandon.signal : AbortSignal.any([signal, abandon.signal])) + return await transport.request(method, params ?? {}, abandon.signal) } finally { clearTimeout(timer) } } catch (error) { - if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError || signal?.aborted) throw error + if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError) throw error // Transport-level failures gain process context: exit code + stderr tail. await this.settleStreams() throw this.closedError(errorMessage(error)) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 209636714a..dbd4623c37 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -12,17 +12,17 @@ en: ac793c588b13ce87 zh: 502015cda46de3ac /deepseek-harness-python-sdk/run-text-and-commands: - en: c781ae575e6be4d2 - zh: cc802c264f8a7a99 + en: 0c5b04951c9b7d32 + zh: b049925160bec5f5 /deepseek-harness-python-sdk/client-approvals: en: fc69be1beb8abff1 zh: 91b56781931a6f68 /deepseek-harness-python-sdk/customize-plugins: - en: 69a6a6745c6e89c1 - zh: 85e82a4cc31e16e5 + en: 0cfa22d329a704db + zh: ff04b5e01e2c5936 /deepseek-harness-python-sdk/native-user-questions: en: 979ad00fe498b2be zh: 4b656bd268292a13 /deepseek-harness-python-sdk/results-and-notifications: - en: e3318c54ef33e859 - zh: 048d2db7bcf5399f + en: 6b81899ea865ffd2 + zh: d9e326a3fb04e696 diff --git a/python/sdk/README.md b/python/sdk/README.md index 20aff3f5a6..b6ffbbe8ad 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -67,7 +67,7 @@ The call returns `RunResult` for an ordinary prompt's inbox-receipt-to-idle inte Content-block arrays stay literal queued prompts and return `RunResult`. For example, `session.run([{"type": "text", "text": "/compact"}])` sends that text to the model, while `session.run("/compact")` invokes native compaction. Content blocks require queue delivery; a steer request raises `ValueError` before sending. -Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command notifications are delivered after the command response, ending at its own `command/done`. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity. +Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command callbacks replay Session-tree notifications from subscription through their own `command/done` after the command response, so that interval can include overlapping work. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity. The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` executes supported command names without prompt fallback. See the [compaction example](examples/README.md#compact-a-full-sdk-session). @@ -125,7 +125,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `reasoning_effort` is an optional non-empty adapter-owned identifier for that exact route; omission preserves the model's own default. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Initialization rejects a missing adapter, unavailable model, or unsupported effort before a prompt runs. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides a platform-selected persistent shell, `read_image`, `working_directory`, local execution, JSONL sessions, and automatic compaction. Text file tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. SDK prompt text does not execute `/compact`. +The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides a platform-selected persistent shell, `read_image`, `working_directory`, local execution, JSONL sessions, and automatic compaction. Text file tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. The `sdk-minimal` profile leaves unsupported slash input on the ordinary prompt path. ## Native user questions @@ -135,7 +135,7 @@ Callbacks run on separate threads so the reader can process cancellation and oth ## Results and notifications -`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. +For ordinary prompts, `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. `NotificationSubscription.next(timeout_seconds=...)` bounds one local notification wait and raises `TimeoutError` on expiry. Omitting the argument waits indefinitely. A timeout neither cancels runtime work nor closes the subscription; a later call can receive subsequent notifications. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 06027da87a..ee516463b3 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -67,7 +67,7 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: 内容块数组保持原样排队输入,并返回 `RunResult`。例如,`session.run([{"type": "text", "text": "/compact"}])` 将该文本发送给模型,而 `session.run("/compact")` 调用原生压缩。内容块要求 queue 投递;请求 steer 会在发送前抛出 `ValueError`。 -每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令通知在命令响应到达后交付,止于其自身的 `command/done`。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。 +每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令回调在命令响应到达后,按顺序交付从订阅时刻到自身 `command/done` 的 Session 树通知,因此该区间可能包含重叠工作的通知。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。 默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 严格执行支持的命令名,不回退为提示词。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 @@ -125,7 +125,7 @@ with DeepSeekHarness( `provider` 选择指定 Cordis 组合所注册的提供方路由;`model` 是该适配器解析出的模型 ID。`reasoning_effort` 是该确切路由可选的非空适配器自有标识符;省略时保留模型自身的默认值。`max_tokens` 是一个可选的正整数,用于限制根 agent 及其进程内后代在每次请求中输出的 token 数量;省略该参数时,由提供方的默认行为决定输出上限。缺少适配器、模型不可用或推理强度不受支持时,初始化会在提示词运行前拒绝。压缩(compaction)摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方专属的凭据和端点,并选择 pi-ai 已安装 catalog 中存在的任意提供方/模型组合。 -随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它提供按平台选择的持久 shell、`read_image`、`working_directory`、本地执行、JSONL 会话和自动压缩。文本文件工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。SDK 提示词文本不会执行 `/compact`。 +随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它提供按平台选择的持久 shell、`read_image`、`working_directory`、本地执行、JSONL 会话和自动压缩。文本文件工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。`sdk-minimal` profile 将不支持的斜杠输入保留在普通提示词路径上。 ## 原生用户问答 @@ -135,7 +135,7 @@ with DeepSeekHarness( ## 结果与通知 -`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 +对于普通提示词,`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 `NotificationSubscription.next(timeout_seconds=...)` 限制单次本地通知等待,超时抛出 `TimeoutError`;省略参数则无限等待。超时不取消运行时工作,也不关闭订阅,后续调用仍可接收通知。 diff --git a/python/sdk/examples/README.i18n.yaml b/python/sdk/examples/README.i18n.yaml index 87c7e2c8bb..e71c9ee019 100644 --- a/python/sdk/examples/README.i18n.yaml +++ b/python/sdk/examples/README.i18n.yaml @@ -7,7 +7,7 @@ zh: bdd91202075498a3 /python-sdk-example/run-the-minimal-agent: en: ac09bb6bc3ab76a0 - zh: 6258540acaf4bc37 + zh: e69d1e2bd796f3bf /python-sdk-example/compact-a-full-sdk-session: en: 7749773f97211ac1 zh: 594348ae66a2ef6c @@ -15,11 +15,11 @@ en: 1fe5e06c6842a53b zh: ff0836d8cffaeef7 /python-sdk-example/review-a-plan: - en: 2596f68bb55082c8 - zh: 1e40ca75e1a0f9b9 + en: e303821f2432111b + zh: f418c686498743d0 /python-sdk-example/run-a-goal: - en: ae73e9fd9d402047 - zh: f9694bc6a6e12929 + en: f4748b8e074f3497 + zh: 271aa49d20d83a73 /python-sdk-example/add-plugins: en: 5e1e30dac13f6c9e zh: 0af98ebb75c88f43 diff --git a/python/sdk/examples/README.md b/python/sdk/examples/README.md index 5a08a0583e..10bf98eb8a 100644 --- a/python/sdk/examples/README.md +++ b/python/sdk/examples/README.md @@ -59,7 +59,7 @@ The command changes the Session's policy while the printed launch setting remain ## Review a plan -The [plan example](plan.py) sends `/plan ` through `run()` and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; EOF dismisses without approving. Approval allows implementation from a later model step, so choose an isolated workspace. +The [plan example](plan.py) sends `/plan ` through `run()` and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; cancellation or EOF before an answer dismisses the review. Approval allows implementation from a later model step, so choose an isolated workspace. ```sh python python/sdk/examples/plan.py \ @@ -68,7 +68,7 @@ python python/sdk/examples/plan.py \ "Plan the requested change and present it for review." ``` -The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait` and rejects truncated or failed root turns; its question callback is independent of the protocol reader. +The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait` and rejects truncated or failed root turns; its question callback is independent of the protocol reader. Ctrl+C stops the example even while it is waiting for input. ## Run a goal @@ -82,7 +82,7 @@ python python/sdk/examples/goal.py \ "Calculate 17 * 19, verify the result, and complete the goal without changing files." ``` -`--timeout-seconds` bounds observation (300 seconds by default). A timeout reports failure and closes the runtime; it does not mark the goal complete. The persisted goal can be inspected after restoration and explicitly resumed. `session/wait` alone is insufficient because automatic rounds have idle gaps. +`--timeout-seconds` requires a finite positive value and bounds the initial command wait and subsequent observation (300 seconds by default). A timeout reports failure and closes the runtime; it does not mark the goal complete. The persisted goal can be inspected after restoration and explicitly resumed. `session/wait` alone is insufficient because automatic rounds have idle gaps. ## Add plugins diff --git a/python/sdk/examples/README.zh.md b/python/sdk/examples/README.zh.md index f01eb3d591..a75075c8d8 100644 --- a/python/sdk/examples/README.zh.md +++ b/python/sdk/examples/README.zh.md @@ -25,7 +25,7 @@ python python/sdk/examples/minimal.py \ - 配合支持图片输入的模型读取 PNG/JPEG/WebP/GIF 文件的 `read_image` - 查询或修改 Session 目录的 `working_directory` -该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、文本文件工具、本地指令发现、设置、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。[自动上下文压缩](../../../packages/bundle/sdk-minimal/README.zh.md#model-experience)通过额外模型请求生成较早历史的摘要,同时保留完整日志。SDK 提示词文本不会执行斜杠命令。 +该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、文本文件工具、本地指令发现、设置、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。[自动上下文压缩](../../../packages/bundle/sdk-minimal/README.zh.md#model-experience)通过额外模型请求生成较早历史的摘要,同时保留完整日志。这个最小组合将不支持的斜杠输入保留在普通提示词路径上。 持久 PTY 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 @@ -59,7 +59,7 @@ python python/sdk/examples/permission.py \ ## 审查计划 -[计划示例](plan.py) 通过 `run()` 发送 `/plan `,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。 +[计划示例](plan.py) 通过 `run()` 发送 `/plan `,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;取消或作答前遇到 EOF 会关闭审查。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。 ```sh python python/sdk/examples/plan.py \ @@ -68,7 +68,7 @@ python python/sdk/examples/plan.py \ "Plan the requested change and present it for review." ``` -原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,并报告根轮次截断或失败;问答回调独立于协议读取线程。 +原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,并报告根轮次截断或失败;问答回调独立于协议读取线程。等待输入时也可以通过 Ctrl+C 结束示例。 ## 运行目标 @@ -82,7 +82,7 @@ python python/sdk/examples/goal.py \ "Calculate 17 * 19, verify the result, and complete the goal without changing files." ``` -`--timeout-seconds` 限制观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。自动轮次之间存在 idle 间隙,因此仅调用 `session/wait` 不足以等待目标结束。 +`--timeout-seconds` 必须是有限正数,用于限制首次命令等待及之后的观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。自动轮次之间存在 idle 间隙,因此仅调用 `session/wait` 不足以等待目标结束。 ## 添加插件 diff --git a/python/sdk/examples/goal.py b/python/sdk/examples/goal.py index 95baa7ec0b..a41d1fa5e1 100644 --- a/python/sdk/examples/goal.py +++ b/python/sdk/examples/goal.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import math import os import time from pathlib import Path @@ -43,11 +44,12 @@ def main() -> None: parser.add_argument("--timeout-seconds", type=float, default=300) parser.add_argument("objective", help="Objective for the native /goal command") args = parser.parse_args() - if not args.objective.strip() or args.timeout_seconds <= 0: - parser.error("objective must be non-blank and timeout-seconds must be positive") + if not args.objective.strip() or not math.isfinite(args.timeout_seconds) or args.timeout_seconds <= 0: + parser.error("objective must be non-blank and timeout-seconds must be finite and positive") with DeepSeekHarness( dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()), profile="sdk", model=args.model, env={"DSH_PERMISSION_MODE": "workspace-write"}, + request_timeout_seconds=args.timeout_seconds, ) as harness: session = harness.start_session() with harness.client.subscribe_notifications(lambda notice: notice.payload.get("sessionId") == session.id) as notifications: diff --git a/python/sdk/examples/plan.py b/python/sdk/examples/plan.py index 3d17df8617..160cedaec7 100644 --- a/python/sdk/examples/plan.py +++ b/python/sdk/examples/plan.py @@ -5,6 +5,8 @@ from __future__ import annotations import argparse import os +import subprocess +import sys import threading from contextlib import closing from pathlib import Path @@ -19,25 +21,121 @@ class WaitResult(BaseModel): """The empty session/wait result.""" -def review(request: UserQuestionRequest, cancelled: threading.Event) -> dict: - """Let the user approve, give feedback, or dismiss the native plan review.""" - if cancelled.is_set(): - return {"cancelled": True} - if len(request.questions) != 1 or request.questions[0].get("intent", {}).get("kind") != "plan-review": - raise RuntimeError("This example handles plan reviews only") - question = request.questions[0] - print(question["detail"]) +class _PlanReviews: + """Own cancellable stdin readers and join review callbacks before interpreter exit.""" + + # A buffered reader can consume later answers from a pipe before it exits. + _READ_LINE = """import os, sys +if sys.stdin.isatty(): try: - while True: - choice = input("[a] Approve, [k] Keep planning, [d] Dismiss: ").strip().lower() - if choice == "d": - return {"cancelled": True} - if choice == "a": - return {"answers": [{"id": question["id"], "selected": ["Approve"]}]} - if choice == "k": - return {"answers": [{"id": question["id"], "selected": ["Keep planning"], "custom": input("Feedback: ")}]} + text = input() + '\\n' except EOFError: - return {"cancelled": True} + text = '' +else: + line = bytearray() + while True: + value = os.read(0, 1) + if not value: + break + line.extend(value) + if value == b'\\n': + break + text = line.decode(sys.stdin.encoding or 'utf-8') +sys.stdout.buffer.write(text.encode('utf-8')) +""" + + def __init__(self) -> None: + self._closed = threading.Event() + self._lock = threading.Lock() + self._question_lock = threading.Lock() + self._readers: list[subprocess.Popen[bytes]] = [] + self._callbacks: set[threading.Thread] = set() + + def __enter__(self) -> _PlanReviews: + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + with self._lock: + self._closed.set() + readers = tuple(self._readers) + callbacks = tuple(self._callbacks) + for reader in readers: + self._terminate(reader) + for reader in readers: + reader.wait() + for callback in callbacks: + if callback is not threading.current_thread(): + callback.join() + + @staticmethod + def _terminate(reader: subprocess.Popen[bytes]) -> None: + if reader.poll() is None: + try: + reader.kill() + except ProcessLookupError as error: + # The reader exited between poll and kill. + pass + + def _read_line(self, prompt: str, cancelled: threading.Event) -> str | None: + with self._lock: + if self._closed.is_set() or cancelled.is_set(): + return None + reader = subprocess.Popen( + [sys.executable, "-c", self._READ_LINE], stdin=sys.stdin, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + self._readers.append(reader) + try: + print(prompt, end="", flush=True) + while True: + if self._closed.is_set() or cancelled.is_set(): + self._terminate(reader) + try: + output, errors = reader.communicate(timeout=0.05) + break + except subprocess.TimeoutExpired: + continue + if self._closed.is_set() or cancelled.is_set(): + return None + if reader.returncode != 0: + raise RuntimeError(f"Plan review input reader failed: {errors.decode(errors='replace').strip()}") + return output.decode("utf-8").removesuffix("\n").removesuffix("\r") if output else None + finally: + self._terminate(reader) + reader.communicate() + with self._lock: + self._readers.remove(reader) + + def answer(self, request: UserQuestionRequest, cancelled: threading.Event) -> dict: + """Serialize plan reviews; cancellation or empty stdin dismisses the current question.""" + with self._lock: + if self._closed.is_set() or cancelled.is_set(): + return {"cancelled": True} + self._callbacks = {callback for callback in self._callbacks if callback.is_alive()} + self._callbacks.add(threading.current_thread()) + while not self._question_lock.acquire(timeout=0.05): + if self._closed.is_set() or cancelled.is_set(): + return {"cancelled": True} + try: + if self._closed.is_set() or cancelled.is_set(): + return {"cancelled": True} + if len(request.questions) != 1 or request.questions[0].get("intent", {}).get("kind") != "plan-review": + raise RuntimeError("This example handles plan reviews only") + question = request.questions[0] + print(question["detail"], flush=True) + while True: + choice = self._read_line("[a] Approve, [k] Keep planning, [d] Dismiss: ", cancelled) + if choice is None or choice.strip().lower() == "d": + return {"cancelled": True} + if choice.strip().lower() == "a": + return {"answers": [{"id": question["id"], "selected": ["Approve"]}]} + if choice.strip().lower() == "k": + feedback = self._read_line("Feedback: ", cancelled) + if feedback is None: + return {"cancelled": True} + return {"answers": [{"id": question["id"], "selected": ["Keep planning"], "custom": feedback}]} + finally: + self._question_lock.release() def display(notification: Notification, session_id: str) -> None: @@ -65,9 +163,9 @@ def main() -> None: args = parser.parse_args() if not args.task.strip(): parser.error("task must be non-blank") - with DeepSeekHarness( + with _PlanReviews() as reviews, DeepSeekHarness( dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()), - profile="sdk", model=args.model, on_user_question=review, + profile="sdk", model=args.model, on_user_question=reviews.answer, env={"DSH_PERMISSION_MODE": "workspace-write"}, ) as harness: session = harness.start_session() diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 0ffe1cca6a..3b12250876 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -232,8 +232,9 @@ class Session: Text returns the prompt's receipt-to-idle RunResult or a settled CommandExecution; command completion does not wait for later model work. Content blocks stay literal and accept only queue delivery. Notifications - retain wire order; command notifications are delivered after its response, - up to its own command/done event. Handler errors remain command results; + retain wire order. Command callbacks replay Session-tree notifications + from subscription through their own command/done after the response, + including overlapping work. Handler errors remain command results; routing, transport and observer failures raise without rolling back work. """ if delivery not in ("queue", "steer"): diff --git a/python/sdk/tests/run_installed_commands.py b/python/sdk/tests/run_installed_commands.py new file mode 100644 index 0000000000..46db0287eb --- /dev/null +++ b/python/sdk/tests/run_installed_commands.py @@ -0,0 +1,67 @@ +"""Run native command regressions against installed SDK and runtime wheels.""" + +from __future__ import annotations + +import os +import runpy +import sys +import tempfile +from pathlib import Path + +import pytest + + +TEST_FILES = ( + "test_commands_runtime.py", + "test_goal_runtime.py", + "test_plan_runtime.py", + "test_plan_example.py", + "test_plan_input.py", +) + + +class RequireExecutedTests: + """Treat skipped collection or execution as a failed installed-wheel check.""" + + def __init__(self) -> None: + self.skipped: set[str] = set() + + def pytest_collectreport(self, report: pytest.CollectReport) -> None: + if report.skipped: + self.skipped.add(report.nodeid) + + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + if report.skipped: + self.skipped.add(report.nodeid) + + def pytest_sessionfinish(self, session: pytest.Session) -> None: + if self.skipped: + print("Installed-wheel native tests skipped: " + ", ".join(sorted(self.skipped)), file=sys.stderr) + session.exitstatus = pytest.ExitCode.TESTS_FAILED + + +def main() -> int: + """Verify wheel imports, then run every selected regression without repository pytest configuration.""" + tests = Path(__file__).resolve().parent + smoke = runpy.run_path(tests.parents[2] / "scripts/smoke-python-runtime.py") + for name in ("PYTHONPATH", "DSH_RUNTIME_MODE", "PYTEST_ADDOPTS", "PYTEST_PLUGINS"): + os.environ.pop(name, None) + os.environ["DSH_COMMANDS_TEST_RUNTIME"] = "installed" + os.environ["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + previous_cwd = Path.cwd() + with tempfile.TemporaryDirectory(prefix="dsh-sdk-native-") as temporary: + try: + os.chdir(temporary) + smoke["assert_installed_wheel_environment"]() + config = Path(temporary) / "pytest.ini" + config.write_text("[pytest]\n", encoding="utf-8") + return pytest.main([ + "-c", str(config), "--noconftest", "--import-mode=prepend", "-ra", + *(str(tests / name) for name in TEST_FILES), + ], plugins=[RequireExecutedTests()]) + finally: + os.chdir(previous_cwd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/sdk/tests/test_goal_example.py b/python/sdk/tests/test_goal_example.py new file mode 100644 index 0000000000..9ec3ab71ef --- /dev/null +++ b/python/sdk/tests/test_goal_example.py @@ -0,0 +1,114 @@ +"""Goal observation deadlines and runtime cleanup through the runnable example.""" + +from __future__ import annotations + +import json +import runpy +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness + + +_EXAMPLE = Path(__file__).resolve().parents[1] / "examples/goal.py" + + +@pytest.mark.parametrize("pending", ["command", "goal-event", "wrapup", "complete"]) +def test_goal_example_bounds_pending_work_and_reaps_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], pending: str, +) -> None: + peer = tmp_path / "goal-peer.py" + observed = tmp_path / "observed.jsonl" + peer.write_text('''import json +import sys +from pathlib import Path + +pending, observed = sys.argv[1:] + +def send(frame): + print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True) + +for line in sys.stdin: + frame = json.loads(line) + with Path(observed).open("a", encoding="utf-8") as stream: + stream.write(json.dumps(frame) + "\\n") + method = frame["method"] + if method == "initialize": + send({"id": frame["id"], "result": {"serverInfo": {"name": "goal-example-peer"}}}) + elif method == "session/query": + assert frame["params"]["text"] == "/goal Finish this task" + session = frame["params"]["sessionId"] + if pending == "command": + continue + if pending in ("wrapup", "complete"): + send({"method": "session.event", "params": {"sessionId": session, "event": { + "type": "goal/change", "data": {"operation": "complete"}}}}) + send({"id": frame["id"], "result": {"kind": "command", "execution": { + "commandId": "goal-command", "result": {"kind": "success", "text": "Goal set"}}}}) + elif method == "session/wait": + if pending == "wrapup": + continue + send({"method": "session.event", "params": {"sessionId": session, "event": { + "type": "assistant/message", "data": {"message": { + "content": [{"type": "text", "text": "Closing reply"}]}}}}}) + send({"id": frame["id"], "result": {}}) + elif method == "shutdown": + send({"id": frame["id"], "result": {}}) + break + else: + raise AssertionError(method) +''', encoding="utf-8") + example = runpy.run_path(str(_EXAMPLE)) + harnesses: list[DeepSeekHarness] = [] + + def configured_harness(**options: object) -> DeepSeekHarness: + harness = DeepSeekHarness( + **options, _launch_args=(sys.executable, str(peer), pending, str(observed)), + initialize_timeout_seconds=10, shutdown_timeout_seconds=10, + ) + harnesses.append(harness) + return harness + + monkeypatch.setitem(example["main"].__globals__, "DeepSeekHarness", configured_harness) + timeout = "10" if pending == "complete" else "1" + monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"), + "--workspace", str(tmp_path), "--timeout-seconds", timeout, "Finish this task"]) + # The outer bound also releases an implementation that forgets its own deadline. + with ThreadPoolExecutor(max_workers=1) as workers: + execution = workers.submit(example["main"]) + try: + if pending == "complete": + execution.result(timeout=30) + else: + expected = {"command": "session/query timed out", "goal-event": "SDK notification", + "wrapup": "session/wait timed out"}[pending] + with pytest.raises(TimeoutError, match=expected): + execution.result(timeout=30) + finally: + for harness in harnesses: + harness.close() + [harness] = harnesses + assert harness.client._proc is None + frames = [json.loads(line) for line in observed.read_text(encoding="utf-8").splitlines()] + methods = [frame["method"] for frame in frames] + assert methods == ["initialize", "session/query", *(["session/wait"] if pending in {"wrapup", "complete"} else []), "shutdown"] + if pending == "complete": + output = capsys.readouterr().out + assert "Goal: complete" in output + assert "Closing reply" in output + + +@pytest.mark.parametrize("timeout", ["0", "-1", "nan", "inf", "-inf"]) +def test_goal_example_rejects_unbounded_or_nonpositive_timeout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], timeout: str, +) -> None: + example = runpy.run_path(str(_EXAMPLE)) + monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"), + f"--timeout-seconds={timeout}", "Finish this task"]) + with pytest.raises(SystemExit) as error: + example["main"]() + assert error.value.code == 2 + assert "timeout-seconds must be finite and positive" in capsys.readouterr().err diff --git a/python/sdk/tests/test_plan_example.py b/python/sdk/tests/test_plan_example.py index 6d21060ead..834ad7e947 100644 --- a/python/sdk/tests/test_plan_example.py +++ b/python/sdk/tests/test_plan_example.py @@ -40,18 +40,14 @@ def test_plan_example_reports_truncation_and_accepts_explicit_dismissal( return response(*args).replace(b'"stop_reason": "end_turn"', b'"stop_reason": "max_tokens"') monkeypatch.setattr(runtime_helpers, "_messages_response", truncated_response) - choices: list[str] = [] - - def dismiss(prompt: str) -> str: - choices.append(prompt) - return "d" - - monkeypatch.setattr("builtins.input", dismiss) + terminal_input = tmp_path / "stdin.txt" + terminal_input.write_text("d\n", encoding="utf-8") monkeypatch.setenv("DSH_TELEMETRY_DISABLED", "1") monkeypatch.setenv("DSH_TOOLS_MODE", "native") monkeypatch.setattr(sys, "argv", [str(_EXAMPLE), "--dsh-home", str(tmp_path / "home"), "--workspace", str(workspace), "Plan a small change."]) - with _ModelServer([completion]) as model: + with terminal_input.open(encoding="utf-8") as terminal, _ModelServer([completion]) as model: + monkeypatch.setattr(sys, "stdin", terminal) def configured_harness(**options: object) -> DeepSeekHarness: return DeepSeekHarness( **options, dsh_bin=_dsh_bin(), patches=(str(patch),), @@ -73,8 +69,9 @@ def test_plan_example_reports_truncation_and_accepts_explicit_dismissal( "max-tokens" if outcome == "max-tokens" else "completed", ] assert [event["data"] for event in _of_type(events, "plan/mode")] == [{"active": True}] - assert len(choices) == (0 if outcome == "max-tokens" else 1) - assert ("PARTIAL_PLAN" if outcome == "max-tokens" else _PLAN) in capsys.readouterr().out + output = capsys.readouterr().out + assert ("[a] Approve" in output) == (outcome == "dismiss") + assert ("PARTIAL_PLAN" if outcome == "max-tokens" else _PLAN) in output def test_plan_display_rejects_root_abort_without_rejecting_descendant_abort() -> None: diff --git a/python/sdk/tests/test_plan_input.py b/python/sdk/tests/test_plan_input.py new file mode 100644 index 0000000000..6026ecf94e --- /dev/null +++ b/python/sdk/tests/test_plan_input.py @@ -0,0 +1,253 @@ +"""Plan example input uses real pipes and releases readers before process exit.""" + +from __future__ import annotations + +import json +import os +import runpy +import signal +import subprocess +import sys +import threading +import time +from contextlib import ExitStack +from pathlib import Path + +import pytest + + +_EXAMPLE = Path(__file__).resolve().parents[1] / "examples/plan.py" +_WAIT_SECONDS = 15 +_PEER = '''import json +from pathlib import Path +import sys +import threading + +root = Path(sys.argv[1]) +lock = threading.Lock() +stop = threading.Event() +wait_id = None +serial = 0 + +def send(frame): + with lock: + print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True) + +def ask(session_id): + global serial + serial += 1 + send({"id": "ask-" + str(serial), "method": "user-question/ask", "params": { + "requestId": "review-" + str(serial), "sessionId": session_id, + "questions": [{"id": "plan-review", "question": "Review the plan?", "detail": "# Small change", + "options": [{"label": "Approve"}, {"label": "Keep planning"}], + "intent": {"kind": "plan-review", "approve": "Approve"}}], + }}) + +def cancel(): + while not stop.wait(0.01): + if (root / "cancel").exists(): + send({"method": "user-question.cancelled", "params": {"requestId": "review-1"}}) + send({"id": wait_id, "result": {}}) + return + +watcher = threading.Thread(target=cancel) +watcher.start() +try: + for line in sys.stdin: + frame = json.loads(line) + method = frame.get("method") + if method is None: + with (root / "answers.jsonl").open("a") as output: + output.write(json.dumps(frame) + "\\n") + result = frame.get("result", {}) + answers = result.get("answers", []) + if answers and answers[0].get("selected") == ["Keep planning"]: + ask(session_id) + else: + send({"id": wait_id, "result": {}}) + elif method == "initialize": + send({"id": frame["id"], "result": {"serverInfo": {"name": "plan-input-peer"}}}) + elif method == "session/query": + send({"id": frame["id"], "result": {"kind": "command", "execution": { + "commandId": "plan-command", "result": {"kind": "success", "text": "Plan mode entered"}}}}) + elif method == "session/wait": + wait_id = frame["id"] + session_id = frame["params"]["sessionId"] + ask(session_id) + elif method == "shutdown": + (root / "shutdown").write_text("done") + send({"id": frame["id"], "result": {}}) + break + else: + raise AssertionError(method) +finally: + stop.set() + watcher.join() +''' +_WRAPPER = '''import json +from pathlib import Path +import runpy +import subprocess +import sys +import threading +from deepseek_harness import DeepSeekHarness + +if sys.platform != "win32": + import resource + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + +example_path, peer, root_argument = sys.argv[1:] +root = Path(root_argument) +example = runpy.run_path(example_path) +readers = [] +popen = subprocess.Popen + +def record_reader(*args, **kwargs): + process = popen(*args, **kwargs) + if args and len(args[0]) > 1 and args[0][1] == "-c": + readers.append(process) + return process + +subprocess.Popen = record_reader + +def configured_harness(**options): + answer = options.pop("on_user_question") + def record_answer(request, cancelled): + result = answer(request, cancelled) + with (root / "callbacks.jsonl").open("a") as output: + output.write(json.dumps(result) + "\\n") + return result + return DeepSeekHarness( + **options, on_user_question=record_answer, _launch_args=(sys.executable, peer, str(root)), + initialize_timeout_seconds=5, shutdown_timeout_seconds=1, + ) + +example["main"].__globals__["DeepSeekHarness"] = configured_harness +sys.argv = [example_path, "--dsh-home", str(root / "home"), "--workspace", str(root), "Plan a change."] +try: + example["main"]() +except KeyboardInterrupt: + raise SystemExit(130) +finally: + (root / "cleanup.json").write_text(json.dumps({ + "readers": [{"pid": reader.pid, "returncode": reader.poll()} for reader in readers], + "callbacks": [thread.name for thread in threading.enumerate() if thread.name == "dsh-user-question"], + })) +''' + + +def _wait_prompt(process: subprocess.Popen[bytes], output: Path, marker: str) -> None: + deadline = time.monotonic() + _WAIT_SECONDS + while marker not in output.read_text(encoding="utf-8"): + assert process.poll() is None, output.read_text(encoding="utf-8") + if time.monotonic() >= deadline: + raise AssertionError(f"plan input did not reach {marker!r}") + time.sleep(0.01) + + +def _run( + tmp_path: Path, data: bytes, *, stage: str | None = None, action: str | None = None, terminal: bool = False, +) -> list[dict]: + peer = tmp_path / "peer.py" + peer.write_text(_PEER, encoding="utf-8") + wrapper = tmp_path / "wrapper.py" + wrapper.write_text(_WRAPPER, encoding="utf-8") + output = tmp_path / "stdout.txt" + errors = tmp_path / "stderr.txt" + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + with ExitStack() as stack, output.open("wb") as stdout, errors.open("wb") as stderr: + if terminal: + master, slave = os.openpty() + writing = stack.enter_context(os.fdopen(master, "wb", buffering=0)) + reading = stack.enter_context(os.fdopen(slave, "rb", buffering=0)) + child_input = reading + else: + child_input = subprocess.PIPE + process = subprocess.Popen( + [sys.executable, str(wrapper), str(_EXAMPLE), str(peer), str(tmp_path)], + stdin=child_input, stdout=stdout, stderr=stderr, env=env, start_new_session=os.name != "nt", + ) + if not terminal: + assert process.stdin is not None + writing = stack.enter_context(process.stdin) + try: + if data: + writing.write(data) + writing.flush() + if action is None and not terminal: + writing.close() + elif action is not None: + marker = "Feedback: " if stage == "feedback" else "[d] Dismiss: " + _wait_prompt(process, output, marker) + if action == "interrupt": + process.send_signal(signal.SIGINT) + else: + (tmp_path / "cancel").write_text("cancel") + expected = 130 if action == "interrupt" else 0 + assert process.wait(timeout=_WAIT_SECONDS) == expected, errors.read_text(encoding="utf-8") + finally: + if os.name != "nt": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError as error: + # Successful example teardown has already removed the process group. + pass + elif process.poll() is None: + taskkill = Path(os.environ["SystemRoot"]) / "System32" / "taskkill.exe" + subprocess.run([str(taskkill), "/PID", str(process.pid), "/T", "/F"], + stdin=subprocess.DEVNULL, capture_output=True, check=False, timeout=_WAIT_SECONDS) + process.wait(timeout=_WAIT_SECONDS) + assert "_enter_buffered_busy" not in errors.read_text(encoding="utf-8") + assert (tmp_path / "shutdown").read_text() == "done" + cleanup = json.loads((tmp_path / "cleanup.json").read_text()) + assert cleanup["readers"] and all(reader["returncode"] is not None for reader in cleanup["readers"]) + assert cleanup["callbacks"] == [] + return [json.loads(line) for line in (tmp_path / "callbacks.jsonl").read_text().splitlines()] + + +@pytest.mark.parametrize("data,expected", [ + (b"a", [{"answers": [{"id": "plan-review", "selected": ["Approve"]}]}]), + (b"d\n", [{"cancelled": True}]), + (b"", [{"cancelled": True}]), + ("invalid\nk\n继续检查\r\nd\n".encode(), [ + {"answers": [{"id": "plan-review", "selected": ["Keep planning"], "custom": "继续检查"}]}, + {"cancelled": True}, + ]), +]) +def test_plan_input_preserves_piped_lines_and_eof(tmp_path: Path, data: bytes, expected: list[dict]) -> None: + assert _run(tmp_path, data) == expected + + +def test_plan_input_print_failure_reaps_reader_before_owner_exit(monkeypatch: pytest.MonkeyPatch) -> None: + example = runpy.run_path(str(_EXAMPLE)) + owner_type = example["_PlanReviews"] + readers = [] + popen = subprocess.Popen + + def record_reader(*args, **kwargs): + reader = popen(*args, **kwargs) + readers.append(reader) + return reader + + def fail_print(*_args, **_kwargs): + raise BrokenPipeError("terminal output closed") + + with ExitStack() as stack, monkeypatch.context() as patch: + read_fd, write_fd = os.pipe() + terminal = stack.enter_context(os.fdopen(read_fd, "r", encoding="utf-8")) + stack.enter_context(os.fdopen(write_fd, "w", encoding="utf-8")) + patch.setattr(sys, "stdin", terminal) + patch.setattr(subprocess, "Popen", record_reader) + patch.setitem(owner_type._read_line.__globals__, "print", fail_print) + with owner_type() as owner: + with pytest.raises(BrokenPipeError, match="terminal output closed"): + owner._read_line("Review: ", threading.Event()) + assert len(readers) == 1 and readers[0].poll() is not None + assert owner._readers == [] + + +@pytest.mark.parametrize("stage", ["choice", "feedback"]) +def test_plan_input_cancellation_reaps_open_pipe_reader(tmp_path: Path, stage: str) -> None: + data = b"k\npartial feedback" if stage == "feedback" else b"partial choice" + assert _run(tmp_path, data, stage=stage, action="protocol") == [{"cancelled": True}] + assert not (tmp_path / "answers.jsonl").exists() diff --git a/python/sdk/tests/test_plan_input_posix.py b/python/sdk/tests/test_plan_input_posix.py new file mode 100644 index 0000000000..6bf346e5b4 --- /dev/null +++ b/python/sdk/tests/test_plan_input_posix.py @@ -0,0 +1,36 @@ +"""POSIX terminal input and SIGINT cleanup for the plan example.""" + +from pathlib import Path +import sys + +import pytest + +from test_plan_input import _run +import test_plan_input + + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX PTYs and SIGINT delivery") + + +def test_plan_input_preserves_terminal_feedback(tmp_path: Path) -> None: + assert _run(tmp_path, "k\n继续检查\nd\n".encode(), terminal=True) == [ + {"answers": [{"id": "plan-review", "selected": ["Keep planning"], "custom": "继续检查"}]}, + {"cancelled": True}, + ] + + +@pytest.mark.parametrize("stage", ["choice", "feedback"]) +def test_plan_input_interrupt_reaps_open_pipe_reader(tmp_path: Path, stage: str) -> None: + data = b"k\npartial feedback" if stage == "feedback" else b"partial choice" + assert _run(tmp_path, data, stage=stage, action="interrupt") == [{"cancelled": True}] + assert not (tmp_path / "answers.jsonl").exists() + + +def test_plan_input_cancellation_ignores_inherited_sigterm_disposition( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + wrapper = test_plan_input._WRAPPER.replace( + "import json\n", "import json\nimport signal\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\n", 1, + ) + monkeypatch.setattr(test_plan_input, "_WRAPPER", wrapper) + assert _run(tmp_path, b"partial choice", stage="choice", action="protocol") == [{"cancelled": True}] diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 29d4dc3354..4fafb4a580 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -1275,6 +1275,30 @@ describe('Python release workflows', () => { expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED') }) + it.each([ + ['POSIX', 'posix', "runner.os != 'Windows'"], + ['Windows', 'windows', "runner.os == 'Windows'"], + ])('runs native command regressions through the installed %s wheels', (platform, suffix, condition) => { + const build = workflowJob(loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml'), 'build') + if (!Array.isArray(build.steps)) throw new TypeError('Python wheel builder must define steps') + const steps = build.steps.filter(isRecord) + const installIndex = steps.findIndex(step => step.id === `smoke-venv-${suffix}`) + const testIndex = steps.findIndex(step => step.name === `Run installed-wheel native command tests (${platform})`) + expect(installIndex).toBeGreaterThanOrEqual(0) + expect(testIndex).toBeGreaterThan(installIndex) + const install = steps[installIndex] + const test = steps[testIndex] + expect(install?.run).toContain('pytest>=8.0') + expect(test).toMatchObject({ if: condition }) + expect(test).not.toHaveProperty('continue-on-error') + expect(test?.run).toContain(`"\${{ steps.smoke-venv-${suffix}.outputs.python }}" -I`) + expect(test?.run).toContain('run_installed_commands.py') + if (platform === 'Windows') { + expect(test).toHaveProperty('shell', 'pwsh') + expect(test?.run).toContain('if ($LASTEXITCODE -ne 0) { throw') + } + }) + it('uses the shared macOS deployment-target check in GitLab', () => { const workflow = loadWorkflow('.gitlab-ci.yml') const runtimeWheel = workflow['.runtime-wheel']