From e122cfb6277742f158770030d3a672901b4f285d Mon Sep 17 00:00:00 2001 From: LogosVeritas Date: Mon, 26 Oct 2026 00:53:20 +0800 Subject: [PATCH] feat(sdk): route native commands through run --- .../profiles/sdk/checkpoints.expected.e2e.ts | 1 + .../profiles/sdk/dynamic-tool-cache.e2e.ts | 1 + .../tests/profiles/sdk/schedule-resume.e2e.ts | 4 +- .../tests/profiles/sdk/user-questions.e2e.ts | 1 + .../profile-continuation.worker.ts | 1 + .../sdk-run-commands/guide.i18n.yaml | 16 + .../v0.2.1-rc.3/sdk-run-commands/guide.md | 21 + .../v0.2.1-rc.3/sdk-run-commands/guide.zh.md | 21 + docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 6 +- docs/user/guide/python-sdk.zh.md | 6 +- packages/sdk/client/README.i18n.yaml | 22 +- packages/sdk/client/README.md | 37 +- packages/sdk/client/README.zh.md | 37 +- packages/sdk/client/src/api.ts | 192 +++------ packages/sdk/client/src/index.ts | 3 +- packages/sdk/client/src/types.ts | 3 - .../tests/cot-translation-projection.spec.ts | 2 +- packages/sdk/client/tests/fake-runtime.ts | 14 +- packages/sdk/client/tests/query.spec.ts | 314 -------------- packages/sdk/client/tests/run.spec.ts | 144 +++++++ packages/sdk/client/tests/sdk-client.spec.ts | 42 +- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 4 +- python/sdk/README.i18n.yaml | 10 +- python/sdk/README.md | 26 +- python/sdk/README.zh.md | 26 +- python/sdk/examples/README.i18n.yaml | 14 +- python/sdk/examples/README.md | 12 +- python/sdk/examples/README.zh.md | 10 +- python/sdk/examples/compact.py | 38 +- python/sdk/examples/goal.py | 12 +- python/sdk/examples/minimal.py | 9 +- python/sdk/examples/permission.py | 40 +- python/sdk/examples/plan.py | 12 +- python/sdk/src/deepseek_harness/api.py | 220 ++++------ python/sdk/src/deepseek_harness/client.py | 87 ++-- python/sdk/tests/test_client.py | 18 +- python/sdk/tests/test_commands.py | 4 +- python/sdk/tests/test_commands_runtime.py | 92 ++--- .../tests/test_cot_translation_projection.py | 2 +- python/sdk/tests/test_goal_runtime.py | 48 ++- python/sdk/tests/test_plan_runtime.py | 32 +- python/sdk/tests/test_queries.py | 391 ------------------ python/sdk/tests/test_run.py | 221 ++++++++++ snapshots/sdk/sdk.snapshot.ts | 23 +- 47 files changed, 880 insertions(+), 1369 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md delete mode 100644 packages/sdk/client/tests/query.spec.ts create mode 100644 packages/sdk/client/tests/run.spec.ts delete mode 100644 python/sdk/tests/test_queries.py create mode 100644 python/sdk/tests/test_run.py diff --git a/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts index 8f486aa25d..efa4eb722e 100644 --- a/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts +++ b/apps/cli/tests/profiles/sdk/checkpoints.expected.e2e.ts @@ -58,6 +58,7 @@ describe.skipIf(!existsSync(dshBin))('sdk-minimal durability checkpoints', () => }) onTestFinished(async () => { await harness.close() }) const result = await harness.run('Reply OK without calling tools.') + if ('commandId' in result) throw new Error('Expected prompt activity, received a command result') expect(modelRequests).toBe(disabled ? 1 : 0) const terminal = result.events.findLast(event => event.type === 'turn/end') expect(terminal).toMatchObject({ data: { reason: { kind: disabled ? 'completed' : 'error' } } }) diff --git a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts index ec5fe6ad94..17411c93de 100644 --- a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts +++ b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts @@ -101,6 +101,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea const events: SessionEvent[] = [] const run = async (prompt: string) => { const result = await session.run(prompt) + if ('commandId' in result) throw new Error('Expected prompt activity, received a command result') events.push(...result.events) const failures = result.events.filter(event => event.type === 'assistant/attempt').map(event => ({ turn: event.data.turn, diff --git a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts index f3efcf6dd1..d031001f95 100644 --- a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts +++ b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts @@ -56,7 +56,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t model: 'deepseek-v4-pro', maxTokens: 5678, initializeTimeoutMs: 30_000, env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-01T00:00:00Z')) } }) clients.push(first) - expect((await first.session('saved').run('Set a reminder')).finalResponse).toBe('ready') + expect(await first.session('saved').run('Set a reminder')).toMatchObject({ finalResponse: 'ready' }) expect(requests).toHaveLength(2) await first.close() restarting = true @@ -68,5 +68,5 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t await vi.waitFor(() => { expect(restored).toHaveLength(1) }, { timeout: 30_000 }) expect(restored[0]).toMatchObject({ model: 'deepseek-v4-flash', max_tokens: 1234 }) const session = await second.resumeSession('saved') - expect((await session.run('Continue after the reminder')).finalResponse).toBe('ready') + expect(await session.run('Continue after the reminder')).toMatchObject({ finalResponse: 'ready' }) }) diff --git a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts index b13b0dd542..9d302090f8 100644 --- a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts +++ b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts @@ -77,6 +77,7 @@ it.each([ return } const result = await pending + if ('commandId' in result) throw new Error('Expected prompt activity, received a command result') expect(seen).toEqual([{ requestId: expect.any(String) as string, sessionId: 'question-session', questions }]) expect(result.finalResponse).toBe('answer received') expect(requests).toHaveLength(2) diff --git a/benchmarks/agent-continuation/profile-continuation.worker.ts b/benchmarks/agent-continuation/profile-continuation.worker.ts index f744add964..765986806f 100644 --- a/benchmarks/agent-continuation/profile-continuation.worker.ts +++ b/benchmarks/agent-continuation/profile-continuation.worker.ts @@ -59,6 +59,7 @@ async function run(root: string): Promise { const booted = performance.now() for (let turn = 0; turn < WORKLOAD.profileTurns; turn++) { const result = await harness.run('Read the synthetic file ' + String(turn), { sessionId: PARENT_ID }) + if ('commandId' in result) throw new Error('Expected prompt activity, received a command result') requests += result.events.filter(event => event.type === 'assistant/message').length for (const event of result.events) { if (event.type !== 'tool/result') continue diff --git a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml new file mode 100644 index 0000000000..ea63c44bc6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md +/: + en: 895b9744646458af + zh: 8828b93b5ebe3953 +/sdk-run-accepts-native-commands: + en: 3976928648fffa99 + zh: 924afd63d5b211e0 +/sdk-run-accepts-native-commands/change: + en: 76f6e1c1f3e082fe + zh: d38c1b68d40d500c +/sdk-run-accepts-native-commands/migration: + en: 5318226850278470 + zh: 4cb635fb9e25f051 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md new file mode 100644 index 0000000000..e5d6450846 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "SDK run methods route supported slash-command strings and can return a native command result." +--- + +# SDK run accepts native commands + +English | [中文](guide.zh.md) + +## Change + +Python `DeepSeekHarness.run()` and `Session.run()`, and their TypeScript equivalents, route non-blank strings through the runtime's ordinary-input and command entry. Supported slash commands execute their native handler. Ordinary text returns `RunResult`; commands return `CommandExecution`. Existing callers that assume every string produces an assistant response must distinguish these results. + +Content-block arrays remain literal queued prompts and return `RunResult`. Command completion does not mean that planning, review, or automatic goal rounds have finished. Handler refusals return a command error result; routing and transport failures raise or reject. + +## Migration + +1. Check the result before reading `final_response` in Python or `finalResponse` in TypeScript. In Python, use `isinstance(result, RunResult)`; in TypeScript, use `'finalResponse' in result`. A command result carries `command_id` in Python or `commandId` in TypeScript, plus `result.kind` and command-owned details. +2. To send slash-prefixed text literally, pass content blocks, for example `session.run([{"type": "text", "text": "/compact"}])`. Use a string when the runtime should recognize commands: `session.run("/compact")`. +3. Keep `on_notification` / `onNotification` for activity notifications. For plan or goal commands that start later work, follow the [SDK command completion rules](../../../../packages/sdk/server/README.md#command-input) before treating the work as finished. +4. Verify that ordinary text still produces the expected `RunResult`, that a supported command returns `CommandExecution`, and that literal content blocks preserve their text. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md new file mode 100644 index 0000000000..a4ec7554e6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/sdk-run-commands/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "SDK run 方法会路由已支持的 slash command 字符串,并可能返回原生命令结果。" +--- + +# SDK run 接受原生命令 + +[English](guide.md) | 中文 + +## 变更 + +Python 的 `DeepSeekHarness.run()`、`Session.run()` 及其 TypeScript 对应方法,会将非空白字符串交给运行时的普通输入与命令入口。已支持的 slash command 执行原生处理器。普通文本返回 `RunResult`,命令返回 `CommandExecution`。假设所有字符串都会产生助手回答的现有调用方,需要区分这两种结果。 + +内容块数组仍作为原样内容排队输入,并返回 `RunResult`。命令完成不表示计划生成、审阅或自动目标轮次已经结束。处理器拒绝操作时返回命令错误结果;路由和传输失败会抛出异常或拒绝 Promise。 + +## 迁移 + +1. 在 Python 中读取 `final_response`、在 TypeScript 中读取 `finalResponse` 前检查结果类型。Python 使用 `isinstance(result, RunResult)`,TypeScript 使用 `'finalResponse' in result`。命令结果在 Python 中携带 `command_id`,在 TypeScript 中携带 `commandId`,并包含 `result.kind` 和命令自身的结果字段。 +2. 要原样发送以斜杠开头的文本,传入内容块,例如 `session.run([{"type": "text", "text": "/compact"}])`。需要运行时识别命令时使用字符串:`session.run("/compact")`。 +3. 继续使用 `on_notification` / `onNotification` 观察活动通知。对于会启动后续工作的 plan 或 goal 命令,先遵循 [SDK 命令完成规则](../../../../packages/sdk/server/README.zh.md#command-input),再判断工作是否已经结束。 +4. 验证普通文本仍产生预期的 `RunResult`、已支持的命令返回 `CommandExecution`,以及原样内容块保留其文本。 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index f911334f40..b9171f2264 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -12,8 +12,8 @@ en: c2b15716ea155625 zh: 0879be26f0afc452 /get-started-with-the-python-sdk/run-the-checked-in-example: - en: 74e1866caf573e6a - zh: 7f034b6f81decf9d + en: 23d04cd9dcb6a530 + zh: e7a9471b9493b888 /get-started-with-the-python-sdk/use-the-sdk-in-your-program: en: d1e350be0ba38196 zh: 893220ee7b12b12f diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 438598c378..e9457784cc 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -91,14 +91,14 @@ python python/sdk/examples/minimal.py ` ::: -The script prints the final assistant response. The selected home receives the generated `sdk-minimal` profile, installed plugins, and uncompressed JSONL session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. +The script prints the final assistant response or the command result supplied by the selected profile. The selected home receives the generated `sdk-minimal` profile, installed plugins, and uncompressed JSONL session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. ## Use the SDK in your program ```python from pathlib import Path -from deepseek_harness import DeepSeekHarness +from deepseek_harness import DeepSeekHarness, RunResult workspace = Path("/absolute/path/to/disposable-workspace").resolve() dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() @@ -115,7 +115,7 @@ with DeepSeekHarness( session_id="example-001", ) -print(result.final_response) +print(result.final_response if isinstance(result, RunResult) else result.result.text) ``` The SDK starts the bundled `dsh --profile sdk-minimal` process lazily and reuses it until context-manager exit. The profile, its persistent patch, the home patch, and any ordered `patches` tuple form the application configuration. There is no separate Python runtime bin or complete-config option. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index b7a86e9860..bb5e15ae2f 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -91,14 +91,14 @@ python python/sdk/examples/minimal.py ` ::: -脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk-minimal` profile、已安装插件,以及 `sessions/` 下的未压缩 JSONL 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 +脚本会打印最终 assistant 响应或所选 profile 提供的命令结果。所选 home 会保存生成的 `sdk-minimal` profile、已安装插件,以及 `sessions/` 下的未压缩 JSONL 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 ## 在程序中使用 SDK ```python from pathlib import Path -from deepseek_harness import DeepSeekHarness +from deepseek_harness import DeepSeekHarness, RunResult workspace = Path("/absolute/path/to/disposable-workspace").resolve() dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() @@ -115,7 +115,7 @@ with DeepSeekHarness( session_id="example-001", ) -print(result.final_response) +print(result.final_response if isinstance(result, RunResult) else result.result.text) ``` SDK 会延迟启动内置的 `dsh --profile sdk-minimal` 进程,并复用到上下文管理器退出。Profile、其持久 patch、home patch 与任何有序 `patches` tuple 共同组成应用配置。不存在独立 Python 运行时 bin 或完整配置选项。 diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index 4b1f7ef5fe..e9e239eca1 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -9,8 +9,8 @@ en: b786affe03adf63c zh: aacb5d4af10ca477 /deepseek-ai-dsh-sdk-client/summary: - en: cdcd88191ab0e186 - zh: 9af8bf55cdf3e663 + en: e8e4db8cf87ef086 + zh: fcfdec6902eb4d4b /deepseek-ai-dsh-sdk-client/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,11 +18,11 @@ en: 6c37c21a2e53376b zh: e707d3e81c1242bd /deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness: - en: 01c58dddd7e62de2 - zh: 6dbd46e244f3f135 -/deepseek-ai-dsh-sdk-client/use-this-package/sending-input-and-receiving-responses: - en: 7f66344ec3aa4c03 - zh: e5454af3eac36ec3 + en: 0e155c826651ff5e + zh: 874fceaa9c13e3fa +/deepseek-ai-dsh-sdk-client/use-this-package/run-text-and-native-commands: + en: 2c62b8477e74d7b5 + zh: 185fd7364e2ec71c /deepseek-ai-dsh-sdk-client/use-this-package/lower-level-control-with-harnessclient: en: 5096b15f67e083b5 zh: 746068458a423a2b @@ -36,8 +36,8 @@ en: 6632c06d819233cc zh: 207d55d8217ec6b1 /deepseek-ai-dsh-sdk-client/understand-the-implementation/source-map: - en: f6386d0b3e0ed379 - zh: e485893aafd97432 + en: fd2b42bb40dfd445 + zh: 45cbb461b1c99759 /deepseek-ai-dsh-sdk-client/understand-the-implementation/owned-activity-flow: en: 94a42babac5f1c7d zh: df9e42ed02f7d002 @@ -54,8 +54,8 @@ en: 7abe0be104a791e7 zh: 445311f78b1069ca /deepseek-ai-dsh-sdk-client/known-limitations-and-deferred-work: - en: 83af5fc89e204323 - zh: 10b4455f6e6ed55d + en: 8a9471513b53821e + zh: 898eb17f3c55ad69 /deepseek-ai-dsh-sdk-client/known-limitations-and-deferred-work/dev-note: en: 6177a56f053936a4 zh: 0f4b20228c2d15da diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index b3b417b5f1..c45923d6f1 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-sdk-client` lets TypeScript programs drive a complete DeepSeek Harness runtime over stdio JSON-RPC. Use `query()` and `receiveResponse()` to send text or slash input separately from receiving notifications and its result; use `run()` to collect a model activity interval. `HarnessClient` provides direct protocol access. Callers may provide `dshBin`; otherwise the client resolves the same-version `@deepseek-ai/dsh` executable. The client owns the subprocess, reports typed failures, and reaps it on `close()` or `await using`. +`dsh-sdk-client` lets TypeScript programs drive a complete DeepSeek Harness runtime over stdio JSON-RPC. Use `run()` for ordinary text, supported slash commands, or literal content blocks, and `onNotification` to observe its notifications. `HarnessClient` provides direct protocol access. Callers may provide `dshBin`; otherwise the client resolves the same-version `@deepseek-ai/dsh` executable. The client owns the subprocess, reports typed failures, and reaps it on `close()` or `await using`. ## Table of Contents @@ -41,6 +41,7 @@ import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' await using harness = new DeepSeekHarness({ profile: 'sdk', + dshHome: '/absolute/path/to/dsh-home', patches: ['./automation.cordis.yml'], provider: 'deepseek-official', model: 'deepseek-v4-flash', @@ -48,34 +49,32 @@ await using harness = new DeepSeekHarness({ maxTokens: 49_152, }) const result = await harness.run('say hi') -console.log(result.finalResponse) +console.log('finalResponse' in result ? result.finalResponse : result.result) ``` -The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; call `close()` (or use `await using`) so the child is always reaped. `start()` memoizes the bounded `initialize` handshake, which carries the workspace cwd, provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The server validates that exact route before it accepts prompts; an omitted effort preserves the model's default. `initializeTimeoutMs` defaults to 10 seconds, and its diagnostic names the selected profile with the retained stderr tail. `run(input, { sessionId?, onNotification? })` accepts text or `SdkPromptContentBlock[]`; an inline raster block carries canonical base64 plus `mimeType` and becomes a durable attachment inside the runtime. The call owns one activity interval: it queues the prompt, waits until its message id appears in a durable inbox receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`, where `finalResponse` is the last committed root-session assistant text in that interval — not a response causally assigned to the prompt, because steering, injected context, and other queued work may contribute before idle. `session(id?)` opens a named or fresh session handle. When a failed handshake is cleaned up successfully, the instance installs a fresh client so a later call retries with a new process until terminal `close()`; if initialization and cleanup both fail, `start()` returns an ordered `AggregateError` and retains the failed client instead of spawning beside a process whose exit is unproved. `maxTokens` caps each root-agent request output and is inherited by in-process descendants; compaction plugins own their separate summary limits. +The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; call `close()` (or use `await using`) so the child is always reaped. `start()` memoizes the bounded `initialize` handshake, which carries the workspace cwd, provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The server validates that exact route before it accepts prompts; an omitted effort preserves the model's default. `initializeTimeoutMs` defaults to 10 seconds, and its diagnostic names the selected profile with the retained stderr tail. `run(input, { sessionId?, onNotification?, delivery? })` accepts text or `SdkPromptContentBlock[]`; an inline raster block carries canonical base64 plus `mimeType` and becomes a durable attachment inside the runtime. For ordinary input, the call owns one activity interval: it queues the prompt, waits until its message id appears in a durable inbox receipt, then collects through the next whole-agent `idle`. That path returns `RunResult { sessionId, finalResponse, events, notifications }`, where `finalResponse` is the last committed root-session assistant text in that interval — not a response causally assigned to the prompt, because steering, injected context, and other queued work may contribute before idle. `session(id?)` opens a named or fresh session handle. When a failed handshake is cleaned up successfully, the instance installs a fresh client so a later call retries with a new process until terminal `close()`; if initialization and cleanup both fail, `start()` returns an ordered `AggregateError` and retains the failed client instead of spawning beside a process whose exit is unproved. `maxTokens` caps each root-agent request output and is inherited by in-process descendants; compaction plugins own their separate summary limits. -### Sending input and receiving responses +### Run text and native commands -`await session.query(text, { delivery: 'queue' })` starts the runtime if needed, sends text, and returns without awaiting runtime routing or execution. Delivery defaults to `queue`; `steer` targets the next available step for ordinary input. The runtime recognizes the [supported command forms and Desktop aliases](../server/README.md#command-input); unrecognized input remains ordinary text. `run()` never dispatches these commands. Query is text-only; use `run()` for content-block prompts. +`await session.run(text, { delivery: 'queue', onNotification })` routes non-blank text through the runtime's [supported command forms and Desktop aliases](../server/README.md#command-input). Unrecognized input remains ordinary text. Delivery defaults to `queue`; `steer` targets the next available step for ordinary text. Content-block arrays remain literal queued prompts; requesting steer for them throws before sending. ```ts import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' -await using harness = new DeepSeekHarness({ profile: 'sdk' }) +await using harness = new DeepSeekHarness({ profile: 'sdk', dshHome: '/absolute/path/to/dsh-home' }) const session = harness.session() -await session.query('/compact') -for await (const message of session.receiveResponse()) { - if ('commandId' in message) console.log(message.result) - else if ('finalResponse' in message) console.log(message.finalResponse) -} +const result = await session.run('/compact') +if ('commandId' in result) console.log(result.result) +else console.log(result.finalResponse) ``` -Each `receiveResponse()` call takes the oldest pending query and returns an independent `AsyncIterableIterator`. It yields buffered notifications, then a final `CommandExecution` for a command or `RunResult` for a prompt. Prompt observation starts at its inbox receipt and ends at the next root idle; command observation needs neither a turn nor idle. Routing/protocol/transport failures surface during reception, while command business refusals remain `result.kind === "error"`. A completed command retains `commandId` and any summary `sourceEventSeq`, not an assistant reply. +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' }])`. -Receivers may run concurrently, so an outstanding model response does not prevent sending and receiving `/compact` with its native busy result. Subscriptions are installed before sending and released before the final result is yielded. Close an unused iterator with `return()`, even before its first `next()`, to discard its buffered events and abandon its local RPC wait. This sends no runtime cancellation and authorizes no automatic retry. Continue servicing the Session until its work finishes or close the harness; 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 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. -Launch options provide initial values, not a shared live configuration object. A permission command changes only its target Session; later queries retain that state, other Sessions keep their own state, and new Sessions use the 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. +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. -`/plan` enters plan mode; `/plan off` leaves; `/plan ` enters and steers the message. Reception ends at its `CommandExecution`, while model work and review can continue. Enter first and send a separate ordinary query when a `RunResult` is needed; for combined input, subscribe before query and wait through `client.request('session/wait', { sessionId })`. Keep question handling alive until that work finishes. Plan state does not change permissions; the native guidance and reviewed exit govern planning. +`run('/plan')` enters plan mode; `/plan off` leaves; `/plan ` enters and steers the message. The call returns `CommandExecution` while model work and review can continue. Enter first and run ordinary text when a `RunResult` is needed. For combined input, subscribe before `run()` and wait through `client.request('session/wait', { sessionId })`; keep question handling alive until the work finishes. Plan state does not change permissions. ### Lower-level control with HarnessClient @@ -114,10 +113,10 @@ The client is two layers over one wire: `DeepSeekHarness` (owned runs) over `Har | File | Role | |---|---| -| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`: pending queries, independent receivers, owned runs and `finalResponse` | +| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`: unified input routing, owned activity intervals and command results | | [`src/client.ts`](src/client.ts) | `HarnessClient`: spawn, handshake, requests, subscription fan-out, typed errors | | [`src/dispose.ts`](src/dispose.ts) | Private teardown ladder: stdin EOF → SIGTERM → SIGKILL to actual exit | -| [`src/types.ts`](src/types.ts) | Launch and timeout options, notifications, `QueryMessage` and `RunResult` | +| [`src/types.ts`](src/types.ts) | Launch and timeout options, notifications, `RunResult` and `CommandExecution` | | [`src/index.ts`](src/index.ts) | Consumer interface: the two client layers and caller-facing types | ### Owned activity flow @@ -162,8 +161,8 @@ None in the client process. Profile, patch, provider, model, and history choices These limits define when the client is a poor fit or needs special care. They are current package constraints, not a comparison with other SDK clients or a task backlog. - **No bundled-runtime resolution** — the client resolves the same-version `@deepseek-ai/dsh` package (or a caller-provided `dshBin`); packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists. -- **No runtime cancellation request** — closing a receiver abandons local observation, not execution. Stopping runtime work requires closing the harness (see the [protocol limitations](../protocol/README.md#known-limitations-and-deferred-work)). -- **No exclusive model response** — prompt results cover receipt-to-idle activity and can include steering or other queued work. Query receivers share the Session's actual event stream; a command result does not own a later turn. +- **No runtime cancellation request** — an RPC timeout or observer failure ends local waiting without stopping runtime work. Stopping runtime work requires closing the harness (see the [protocol limitations](../protocol/README.md#known-limitations-and-deferred-work)). +- **No exclusive model response** — prompt results cover receipt-to-idle activity and can include steering or other queued work. Concurrent calls observe the Session's actual event stream; a command result does not own a later turn. - **Callbacks must release their own resources** — the SDK delivers cancellation through `AbortSignal`; application work that ignores it may continue after the SDK stops awaiting its answer. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index 55614e0f39..759841842e 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-sdk-client` 让 TypeScript 程序通过 stdio JSON-RPC 驱动完整的 DeepSeek Harness 运行时。使用 `query()` 与 `receiveResponse()` 可分开发送文本或 slash 输入与接收通知及结果;使用 `run()` 可收集模型活动区间。`HarnessClient` 提供直接协议访问。调用方可以提供 `dshBin`;否则客户端解析同版本的 `@deepseek-ai/dsh` 可执行文件。客户端持有子进程,报告类型化错误,并在 `close()` 或 `await using` 时回收进程。 +`dsh-sdk-client` 让 TypeScript 程序通过 stdio JSON-RPC 驱动完整的 DeepSeek Harness 运行时。使用 `run()` 运行普通文本、支持的 slash 命令或原样内容块,通过 `onNotification` 观察其通知。`HarnessClient` 提供直接协议访问。调用方可以提供 `dshBin`;否则客户端解析同版本的 `@deepseek-ai/dsh` 可执行文件。客户端持有子进程,报告类型化错误,并在 `close()` 或 `await using` 时回收进程。 ## 目录 @@ -41,6 +41,7 @@ import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' await using harness = new DeepSeekHarness({ profile: 'sdk', + dshHome: '/absolute/path/to/dsh-home', patches: ['./automation.cordis.yml'], provider: 'deepseek-official', model: 'deepseek-v4-flash', @@ -48,34 +49,32 @@ await using harness = new DeepSeekHarness({ maxTokens: 49_152, }) const result = await harness.run('say hi') -console.log(result.finalResponse) +console.log('finalResponse' in result ? result.finalResponse : result.result) ``` -子进程在首次使用时惰性启动,并在多次 `run()` 调用之间持续归实例所有;请调用 `close()`(或使用 `await using`),子进程才总能被回收。`start()` 会记忆化有界的 `initialize` 握手,其中包含工作区 cwd、提供方/模型路由、可选且由适配器持有的 `reasoningEffort`,以及可选的正整数 `maxTokens` 输出上限。服务器会在接受提示词前校验该确切路由;省略推理强度时保留模型自身的默认值。`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。`run(input, { sessionId?, onNotification? })` 接受文本或 `SdkPromptContentBlock[]`;内联栅格图像块携带规范 base64 与 `mimeType`,并在运行时内变成持久附件。该调用拥有一个活动区间:它将提示词排入队列,等待其消息 id 出现在持久入队回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`,其中 `finalResponse` 是该区间内根会话最后提交的助手文本——并非因果上归属于该提示词的响应,因为 steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`session(id?)` 打开具名或全新的会话句柄。握手失败且清理成功时,实例会换入全新客户端,使后续调用用新进程重试,直到终结性的 `close()`;如果初始化和清理均失败,`start()` 会返回保留两个原因的有序 `AggregateError`,并继续保留失败的客户端,避免在原进程退出尚未得到证明时启动另一个进程。`maxTokens` 限制每个根 agent 请求的输出量,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。 +子进程在首次使用时惰性启动,并在多次 `run()` 调用之间持续归实例所有;请调用 `close()`(或使用 `await using`),子进程才总能被回收。`start()` 会记忆化有界的 `initialize` 握手,其中包含工作区 cwd、提供方/模型路由、可选且由适配器持有的 `reasoningEffort`,以及可选的正整数 `maxTokens` 输出上限。服务器会在接受提示词前校验该确切路由;省略推理强度时保留模型自身的默认值。`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。`run(input, { sessionId?, onNotification?, delivery? })` 接受文本或 `SdkPromptContentBlock[]`;内联栅格图像块携带规范 base64 与 `mimeType`,并在运行时内变成持久附件。对于普通输入,该调用拥有一个活动区间:它将提示词排入队列,等待其消息 id 出现在持久入队回执中,然后持续收集到整个 agent 下一次进入 `idle`。该路径返回 `RunResult { sessionId, finalResponse, events, notifications }`,其中 `finalResponse` 是该区间内根会话最后提交的助手文本——并非因果上归属于该提示词的响应,因为 steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`session(id?)` 打开具名或全新的会话句柄。握手失败且清理成功时,实例会换入全新客户端,使后续调用用新进程重试,直到终结性的 `close()`;如果初始化和清理均失败,`start()` 会返回保留两个原因的有序 `AggregateError`,并继续保留失败的客户端,避免在原进程退出尚未得到证明时启动另一个进程。`maxTokens` 限制每个根 agent 请求的输出量,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。 -### 发送输入与接收响应 +### 运行文本与原生命令 -`await session.query(text, { delivery: 'queue' })` 在需要时启动运行时、发送文本,然后返回,不等待运行时的路由或执行结果。delivery 默认为 `queue`;对普通输入,`steer` 指向下一个可用步骤。运行时识别[支持的命令形式与 Desktop 别名](../server/README.zh.md#command-input);未识别的输入保持普通文本。`run()` 不分派这些命令。query 只接受文本,内容块提示词请使用 `run()`。 +`await session.run(text, { delivery: 'queue', onNotification })` 将非空白文本交给运行时的[命令形式与 Desktop 别名](../server/README.zh.md#command-input)识别规则。未识别的输入保持普通文本。delivery 默认为 `queue`;对普通文本,`steer` 指向下一个可用步骤。内容块数组仍作为原样内容排队输入;为其请求 steer 会在发送前抛出异常。 ```ts import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' -await using harness = new DeepSeekHarness({ profile: 'sdk' }) +await using harness = new DeepSeekHarness({ profile: 'sdk', dshHome: '/absolute/path/to/dsh-home' }) const session = harness.session() -await session.query('/compact') -for await (const message of session.receiveResponse()) { - if ('commandId' in message) console.log(message.result) - else if ('finalResponse' in message) console.log(message.finalResponse) -} +const result = await session.run('/compact') +if ('commandId' in result) console.log(result.result) +else console.log(result.finalResponse) ``` -每次 `receiveResponse()` 调用取出最早待接收的 query,并返回独立的 `AsyncIterableIterator`。它先产出缓冲通知,再产出最终结果:命令返回 `CommandExecution`,普通提示词返回 `RunResult`。提示词观察从其入队回执开始,到根 agent 下一次 idle 结束;命令观察不需要 turn 或 idle。路由、协议及传输错误在接收时报告,命令业务拒绝仍为 `result.kind === "error"`。已完成的命令保留 `commandId` 及可选摘要 `sourceEventSeq`,而不是助手回复。 +字符串被识别为命令时返回 `CommandExecution`,普通提示词活动返回 `RunResult`。命令完成不需要 turn 或 idle 通知。业务拒绝仍为 `result.kind === "error"`;路由、协议、传输和观察回调失败会拒绝 Promise。命令保留 `commandId` 及可选摘要 `sourceEventSeq`,不会选取助手回复作为结果。要原样发送斜杠文本,使用 `session.run([{ type: 'text', text: '/compact' }])`。 -多个接收器可以并行运行,因此等待模型响应时仍可发送并接收 `/compact` 的原生 busy 结果。订阅在发送前建立,在最终结果产出前释放。对不再使用的迭代器调用 `return()`,即使尚未调用第一次 `next()`,也会丢弃缓冲事件并放弃本地 RPC 等待。该操作不发送运行时取消,也不允许自动重试。请持续服务 Session,直到其工作结束,或关闭 harness;命令完成不代表独立模型活动结束。 +每次调用都拥有自己的订阅,在发送前安装,在调用完成前释放。`onNotification` 按线上的顺序观察普通输入从入队回执到 idle 的活动。命令通知在其响应到达后交付,止于自身的 `command/done`。并发调用保留各自结果,所以运行中的模型轮次可以与命令的原生 busy 结果并存。超时或观察回调失败不回滚运行时工作,也不授权自动重试;命令完成不会结束独立的模型活动。 -启动选项提供初始值,不是共享的实时配置对象。权限命令只修改目标 Session;后续 query 保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。命令不更新 options 对象或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外 step。原生切换和恢复行为见[权限预设](../../interaction/permission-presets/README.zh.md)。 +启动选项提供初始值。权限命令只修改目标 Session;后续输入与恢复保留该状态,其他 Session 保留自己的状态,新 Session 使用配置默认值。命令不修改选项对象或已保存的用户默认值。运行中的审批策略变化可以注入通知并触发下一步骤。原生转换与恢复行为见[权限 preset](../../interaction/permission-presets/README.zh.md)。 -`/plan` 进入计划模式,`/plan off` 退出,`/plan ` 进入并 steer 正文。接收在其 `CommandExecution` 处结束,模型工作与审查可能继续。需要 `RunResult` 时先进入模式再单独提交普通 query;组合输入应在 query 前订阅,并通过 `client.request('session/wait', { sessionId })` 等待。工作结束前应保持问答处理。计划状态不修改权限;规划由原生指导和审查退出流程控制。 +`run('/plan')` 进入计划模式;`/plan off` 退出;`/plan ` 进入并 steer 正文。调用返回 `CommandExecution` 时,模型工作和审阅仍可能继续。需要 `RunResult` 时,先进入,再运行普通文本。组合输入应在 `run()` 前订阅,并通过 `client.request('session/wait', { sessionId })` 等待;保持问答处理器存活直到工作结束。计划状态不修改权限。 ### 用 HarnessClient 做低层控制 @@ -114,10 +113,10 @@ for await (const message of session.receiveResponse()) { | 文件 | 职责 | |---|---| -| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`:待接收 query、独立接收器、自有运行与 `finalResponse` | +| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`:统一输入路由、自有活动区间与命令结果 | | [`src/client.ts`](src/client.ts) | `HarnessClient`:spawn、握手、请求、订阅扇出、类型化错误 | | [`src/dispose.ts`](src/dispose.ts) | 私有关闭阶梯:stdin EOF → SIGTERM → SIGKILL 直到真正退出 | -| [`src/types.ts`](src/types.ts) | 启动与超时选项、通知、`QueryMessage` 与 `RunResult` | +| [`src/types.ts`](src/types.ts) | 启动与超时选项、通知、`RunResult` 与 `CommandExecution` | | [`src/index.ts`](src/index.ts) | 消费方接口:两层客户端与面向调用方的类型 | ### 自有活动流程 @@ -162,8 +161,8 @@ for await (const message of session.receiveResponse()) { 这些限制说明本客户端何时不合适或需要特别注意。它们是当前包约束,不是与其他 SDK 客户端的对比或任务积压。 - **无捆绑运行时解析**——客户端解析同版本 `@deepseek-ai/dsh` 包(或调用方提供的 `dshBin`);打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。 -- **无运行时取消请求**——关闭接收器只放弃本地观察,不停止执行。停止运行时工作需要关闭 harness(见[协议限制](../protocol/README.zh.md#known-limitations-and-deferred-work))。 -- **无独占模型响应**——提示词结果涵盖从回执到 idle 的活动,可能包含 steering 或其他排队工作。query 接收器共享 Session 的实际事件流;命令结果不独占后续 turn。 +- **无运行时取消请求**——RPC 超时或观察回调失败会结束本地等待,不会停止运行时工作。停止运行时工作需要关闭 harness(见[协议限制](../protocol/README.zh.md#known-limitations-and-deferred-work))。 +- **无独占模型响应**——提示词结果涵盖从回执到 idle 的活动,可能包含 steering 或其他排队工作。并发调用观察 Session 的实际事件流;命令结果不独占后续 turn。 - **回调必须释放自己的资源**——SDK 通过 `AbortSignal` 发出取消;忽略它的应用工作可能在 SDK 停止等待答案后继续运行。 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 26dcfe3273..3fad011967 100644 --- a/packages/sdk/client/src/api.ts +++ b/packages/sdk/client/src/api.ts @@ -1,7 +1,6 @@ /** - * High-level input and run APIs over {@link HarnessClient}. Sessions can send - * input independently of receiving its notifications and result, or collect - * a prompt's receipt-to-idle activity through `run()`. + * High-level run API over {@link HarnessClient}: route text to prompts or native + * commands, collect prompt activity, and retain literal content-block delivery. * * @module @deepseek-ai/dsh-sdk-client/api */ @@ -9,11 +8,10 @@ import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -import type { SessionQueryResult } from '@deepseek-ai/dsh-sdk-protocol' +import type { CommandExecution } from '@deepseek-ai/dsh-sdk-protocol' import { createProcessHarnessClient, HarnessClient, isRecord, SdkProtocolError } from './client.ts' -import type { NotificationSubscription } from './client.ts' import type { RuntimeProcessOptions } from './launch.ts' -import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, QueryMessage, RunResult, SdkPromptContentBlock } from './types.ts' +import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, RunResult, SdkPromptContentBlock } from './types.ts' /** * Reusable SDK for running DeepSeek Harness agent turns in a runtime @@ -122,12 +120,20 @@ export class DeepSeekHarness implements AsyncDisposable { } /** - * Run one prompt on a fresh (or named) session. - * @param input - prompt text, or content blocks sent verbatim. + * Queue literal content blocks on a fresh or named Session. + * @param input - content blocks sent without command routing. * @param options - optional session id and per-notification observer. - * @returns the owned activity interval. + * @returns the prompt activity interval. */ - run(input: string | SdkPromptContentBlock[], options?: RunOptions): Promise { + run(input: SdkPromptContentBlock[], options?: RunOptions): Promise + /** + * Run text or literal content on a fresh or named Session. + * @param input - text for native routing, or queued content blocks. + * @param options - optional Session identity, text delivery and notification observer. + * @returns ordinary prompt activity or the native command result. + */ + run(input: string | SdkPromptContentBlock[], options?: RunOptions): Promise + run(input: string | SdkPromptContentBlock[], options?: RunOptions): Promise { return this.session(options?.sessionId).run(input, options) } @@ -169,25 +175,19 @@ export function createProcessDeepSeekHarness( export interface RunOptions { /** Session id to run on; omitted mints a fresh session per call. */ sessionId?: string - /** Observer invoked with every notification for this session tree, in wire order. */ + /** 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. */ onNotification?: (notification: HarnessNotification) => void } -/** Ordinary prompt delivery for one unified input; command admission remains runtime-owned. */ -export interface QueryOptions { - /** Queue a new turn by default, or steer the next available step. */ - delivery?: 'queue' | 'steer' -} - -type QueryOutcome = - | { readonly kind: 'result'; readonly result: SessionQueryResult } - | { readonly kind: 'error'; readonly error: unknown } - -interface PendingQuery { - readonly outcome: Promise - readonly subscription: NotificationSubscription - readonly abandon: AbortController - readonly commandNotifications: HarnessNotification[] +/** Resolve text delivery while keeping content-block input on its queued prompt path. */ +function resolveRunDelivery(input: string | SdkPromptContentBlock[], requested: RunOptions['delivery']): 'queue' | 'steer' { + const delivery = requested ?? 'queue' + if (typeof input !== 'string' && delivery === 'steer') { + throw new TypeError('content-block input requires queue delivery') + } + return delivery } /** Collect an ordinary prompt's notifications from its inbox receipt through idle. */ @@ -225,104 +225,12 @@ class PromptActivity { * One SDK session: a stable id plus owned activity intervals. */ export class HarnessSession { - private readonly pendingQueries: PendingQuery[] = [] - /** * @param harness - the owning harness (supplies the client and handshake). * @param id - the wire session id this handle runs on. */ constructor(readonly harness: DeepSeekHarness, readonly id: string) {} - /** - * Send text for runtime prompt-or-command routing without waiting for its result. - * @param text - complete input text; the runtime interprets slash input. - * @param options - ordinary prompt delivery, defaulting to a new queued turn. - * @returns after initialization and sending; routing failures surface from `receiveResponse()`. - */ - async query(text: string, options: QueryOptions = {}): Promise { - await this.harness.start() - const client = this.harness.client - const subscription = client.subscribeSessionTree(this.id) - const abandon = new AbortController() - const commandNotifications: HarnessNotification[] = [] - const outcome = client.query(this.id, text, options.delivery ?? 'queue', abandon.signal).then( - (result) => { - if (result.kind === 'command') { - for (let notification = subscription.tryNext(); notification !== undefined; notification = subscription.tryNext()) { - commandNotifications.push(notification) - const event = notification.params.event - if (notification.method === 'session.event' && notification.params.sessionId === this.id - && isRecord(event) && event.type === 'command/done' && isRecord(event.data) - && event.data.commandId === result.execution.commandId) break - } - subscription.close() - } - return { kind: 'result', result } - }, - ).catch((error: unknown): QueryOutcome => { - subscription.close() - return { kind: 'error', error } - }) - this.pendingQueries.push({ outcome, subscription, abandon, commandNotifications }) - } - - /** - * Observe the oldest query not yet assigned to a receiver. - * @returns an iterator of notifications followed by `RunResult` or `CommandExecution`. - * Its resources close before the final result is yielded. Returning early abandons - * only local observation, including before the first `next()`; runtime work continues. - * @throws when no query awaits reception, or when its request, protocol, or transport fails. - */ - receiveResponse(): AsyncIterableIterator { - const pending = this.pendingQueries.shift() - if (pending === undefined) throw new Error('No query is waiting for a response') - const isClosed = (): boolean => pending.abandon.signal.aborted - const close = (): void => { - if (isClosed()) return - pending.abandon.abort(new Error('Query observation closed; runtime work was not cancelled')) - pending.subscription.close() - pending.commandNotifications.length = 0 - } - const sessionId = this.id - const iterator = (async function* (): AsyncGenerator { - try { - const outcome = await pending.outcome - if (isClosed()) return - if (outcome.kind === 'error') throw outcome.error - if (outcome.result.kind === 'command') { - for (const notification of pending.commandNotifications) yield notification - close() - yield outcome.result.execution - return - } - const activity = new PromptActivity(sessionId, outcome.result.messageId) - while (!isClosed()) { - const notification = await pending.subscription.next() - if (isClosed()) return - if (!activity.record(notification)) continue - yield notification - if (notification.method === 'session.status' && notification.params.sessionId === sessionId - && notification.params.status === 'idle') break - } - if (isClosed()) return - close() - yield activity.result() - } catch (error: unknown) { - if (!isClosed()) throw error - } finally { - close() - } - })() - return { - [Symbol.asyncIterator]() { return this }, - next: () => iterator.next(), - return: () => { - close() - return iterator.return(undefined) - }, - } - } - /** * Read the effective directory and recover a missing directory to the Session origin. * @returns the absolute directory used by new independent operations. @@ -343,21 +251,45 @@ export class HarnessSession { } /** - * Queue one prompt, then observe the whole session through its next idle. - * @param input - prompt text, or content blocks sent verbatim. - * @param options - optional per-notification observer. - * @returns the owned activity interval; rejects on transport loss, timeout, - * or a protocol error. + * Queue literal content blocks and observe their receipt-to-idle activity. + * @param input - content blocks sent without command routing. + * @param options - a notification observer; content blocks require queue delivery. + * @returns the prompt activity interval. */ - async run(input: string | SdkPromptContentBlock[], options?: Pick): Promise { + run(input: SdkPromptContentBlock[], options?: Omit): Promise + /** + * Run text through native input routing, or queue literal content blocks. + * @param input - non-blank text, or content blocks sent verbatim. + * @param options - text delivery and a per-notification observer. + * @returns the prompt's receipt-to-idle activity or a settled command; command + * completion does not wait for later model work. Handler errors remain results. + * @throws on routing, protocol, transport or observer failure; runtime work is not rolled back. + */ + run(input: string | SdkPromptContentBlock[], options?: Omit): Promise + async run(input: string | SdkPromptContentBlock[], options?: Omit): Promise { + const delivery = resolveRunDelivery(input, options?.delivery) await this.harness.start() const client = this.harness.client - const contentBlocks = normalizeInput(input) const subscription = client.subscribeSessionTree(this.id) - let activity: PromptActivity try { - const messageId = await client.prompt(this.id, contentBlocks) - activity = new PromptActivity(this.id, messageId) + let messageId: string + if (typeof input === 'string') { + const response = await client.query(this.id, input, delivery) + if (response.kind === 'command') { + for (let notification = subscription.tryNext(); notification !== undefined; notification = subscription.tryNext()) { + options?.onNotification?.(notification) + const event = notification.params.event + if (notification.method === 'session.event' && notification.params.sessionId === this.id + && isRecord(event) && event.type === 'command/done' && isRecord(event.data) + && event.data.commandId === response.execution.commandId) break + } + return response.execution + } + messageId = response.messageId + } else { + messageId = await client.prompt(this.id, input) + } + const activity = new PromptActivity(this.id, messageId) while (true) { const notification = await subscription.next() if (!activity.record(notification, options?.onNotification)) continue @@ -365,12 +297,12 @@ export class HarnessSession { && notification.params.sessionId === this.id && notification.params.status === 'idle') break } + return activity.result() } finally { subscription.close() } - - return activity.result() } + } /** diff --git a/packages/sdk/client/src/index.ts b/packages/sdk/client/src/index.ts index 85170d8d37..e53c9bac99 100644 --- a/packages/sdk/client/src/index.ts +++ b/packages/sdk/client/src/index.ts @@ -10,7 +10,7 @@ */ export { DeepSeekHarness, HarnessSession, validatedSessionEvent } from './api.ts' -export type { QueryOptions, RunOptions } from './api.ts' +export type { RunOptions } from './api.ts' export { HarnessClient, RequestTimeoutError, @@ -30,6 +30,5 @@ export type { HarnessClientOptions, HarnessNotification, NotificationFilter, - QueryMessage, RunResult, } from './types.ts' diff --git a/packages/sdk/client/src/types.ts b/packages/sdk/client/src/types.ts index 8182686376..f5b29994b9 100644 --- a/packages/sdk/client/src/types.ts +++ b/packages/sdk/client/src/types.ts @@ -82,9 +82,6 @@ export interface RunResult { notifications: HarnessNotification[] } -/** One query observation: notifications followed by a final activity or native command result. */ -export type QueryMessage = HarnessNotification | RunResult | CommandExecution - /** Re-exported content-block alias so SDK callers need no extra import. */ export type { ContentBlock } export type { CommandExecution } diff --git a/packages/sdk/client/tests/cot-translation-projection.spec.ts b/packages/sdk/client/tests/cot-translation-projection.spec.ts index b9a3fff293..5c6f97a4fb 100644 --- a/packages/sdk/client/tests/cot-translation-projection.spec.ts +++ b/packages/sdk/client/tests/cot-translation-projection.spec.ts @@ -55,7 +55,7 @@ it('preserves common translation requests and results in SDK events without repl description: 'scripted SDK audit projection', initializeTimeoutMs: 5000, }) try { - const result = await harness.run('Keep the original response.') + const result = await harness.run([{ type: 'text', text: 'Keep the original response.' }]) const projection = { finalResponse: result.finalResponse, auditEvents: result.events.filter(isTranslationAudit), diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index a70d78e6cb..0ec03321ab 100644 --- a/packages/sdk/client/tests/fake-runtime.ts +++ b/packages/sdk/client/tests/fake-runtime.ts @@ -330,7 +330,17 @@ reader.on('line', (line) => { } return } + case 'session/query': case 'session/prompt': { + if (frame.method === 'session/query' && frame.params?.text === '/compact') { + const execution: unknown = env.FAKE_COMMAND_RESULT === undefined + ? { commandId: 'command-1', result: { kind: 'success', text: '/compact' } } + : JSON.parse(env.FAKE_COMMAND_RESULT) + respond({ kind: 'command', execution }) + return + } + const promptResult = (messageId: string): object => frame.method === 'session/query' + ? { kind: 'prompt', messageId } : { messageId } const sessionId = sessionIdOf(frame.params) const messageId = `fake-user-${seq}` event(sessionId, 'agent/inbox/spliced', { @@ -355,7 +365,7 @@ reader.on('line', (line) => { } if (env.FAKE_EXIT_DURING_PROMPT !== undefined) { const partial = env.FAKE_TEXT ?? 'partial before exit' - respond({ messageId }) + respond(promptResult(messageId)) event(sessionId, 'assistant/message', { turn: 0, step: 0, @@ -379,7 +389,7 @@ reader.on('line', (line) => { runTurn(sessionId) notify('session.status', { sessionId, status: 'idle' }) settledSessions.add(sessionId) - respond({ messageId }) + respond(promptResult(messageId)) return } case 'session/wait': diff --git a/packages/sdk/client/tests/query.spec.ts b/packages/sdk/client/tests/query.spec.ts deleted file mode 100644 index dc36f66db7..0000000000 --- a/packages/sdk/client/tests/query.spec.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** Unified input receipt and observation over an independently running protocol peer. */ -import { fileURLToPath } from 'node:url' -import { expect, it, onTestFinished, vi } from 'vitest' -import { createProcessDeepSeekHarness } from '../src/api.ts' -import { JsonRpcResponseError, RequestTimeoutError, SdkProtocolError, TransportClosedError } from '../src/index.ts' -import type { QueryMessage, RunResult } from '../src/types.ts' - -function harnessWith(environment: Record = {}, requestTimeoutMs?: number) { - const harness = createProcessDeepSeekHarness({ - command: process.execPath, - args: [fileURLToPath(new URL('./fixtures/query.ts', import.meta.url))], - environment: () => ({ ...process.env, ...environment }), - description: 'query protocol fixture', - initializeTimeoutMs: 5000, - ...requestTimeoutMs === undefined ? {} : { requestTimeoutMs }, - }) - onTestFinished(() => harness.close()) - return harness -} - -async function collect(iterator: AsyncIterableIterator): Promise { - const messages: QueryMessage[] = [] - for await (const message of iterator) messages.push(message) - return messages -} - -function finalRun(messages: QueryMessage[]): RunResult { - const result = messages.at(-1) - if (result === undefined || !('finalResponse' in result)) throw new Error('query did not yield RunResult') - return result -} - -it('sends a command without waiting for its result and buffers the complete command notifications', async () => { - const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }) - const session = harness.session('main') - await session.query('/compact') - expect(await harness.client.request('test/state')).toMatchObject({ - queries: [{ sessionId: 'main', text: '/compact', delivery: 'queue' }], waitingCommands: 1, - }) - await harness.client.request('test/release') - const messages = await collect(session.receiveResponse()) - expect(messages.map(message => 'method' in message ? message.method : 'command')).toEqual([ - 'session.event', 'session.event', 'command', - ]) - expect(messages.at(-1)).toEqual({ commandId: 'command-1', result: { kind: 'success', text: 'compacted', sourceEventSeq: 0 } }) -}) - -it('receives each pending prompt in FIFO order and forwards explicit steer delivery', async () => { - const session = harnessWith().session('main') - await session.query('first') - await session.query('second', { delivery: 'steer' }) - const first = finalRun(await collect(session.receiveResponse())) - const second = finalRun(await collect(session.receiveResponse())) - expect(first.finalResponse).toBe('reply:first') - expect(second.finalResponse).toBe('reply:second') - expect(first.notifications[0]).toMatchObject({ params: { event: { type: 'agent/inbox/spliced', data: { target: 'next-turn' } } } }) - expect(second.notifications[0]).toMatchObject({ params: { event: { type: 'agent/inbox/spliced', data: { target: 'next-step' } } } }) - expect(first.events.some(event => event.type === 'assistant/message' && event.data.message.content.some(block => - block.type === 'text' && block.text === 'before receipt'))).toBe(false) - expect(() => session.receiveResponse()).toThrow('No query') -}) - -it('keeps root result events separate from discovered descendant notifications', async () => { - const session = harnessWith().session('main') - await session.query('with-child') - const messages = await collect(session.receiveResponse()) - const result = finalRun(messages) - expect(result.finalResponse).toBe('reply:with-child') - expect(result.notifications.some(notification => notification.params.sessionId === 'child')).toBe(true) - expect(result.notifications.some(notification => notification.params.sessionId === 'unrelated')).toBe(false) - expect(result.events.filter(event => event.type === 'assistant/message')).toHaveLength(1) -}) - -it('receives compact busy while an earlier receiver is still waiting for model activity', async () => { - const harness = harnessWith() - const session = harness.session('main') - await session.query('hold') - const running = collect(session.receiveResponse()) - await session.query('/compact') - const command = await collect(session.receiveResponse()) - expect(command.at(-1)).toEqual({ commandId: 'command-2', result: { kind: 'error', text: 'busy' } }) - expect(await harness.client.request('test/state')).toMatchObject({ waitingPrompts: 1 }) - await harness.client.request('test/release') - expect(finalRun(await running).finalResponse).toBe('reply:hold') -}) - -it('retains a rejection before reception without creating an unhandled rejected promise', async () => { - const harness = harnessWith({ QUERY_ERROR_TEXT: 'reject this query' }) - const session = harness.session('main') - await session.query('reject this query') - await harness.client.request('test/state') - await session.query('after rejection') - await expect(collect(session.receiveResponse())).rejects.toThrow(JsonRpcResponseError) - expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:after rejection') -}) - -it('accepts ordinary prompt routing for other slash input and preserves its text', async () => { - const session = harnessWith().session('main') - await session.query('/model') - expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:/model') - await session.query(' /compact extra ') - expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply: /compact extra ') -}) - -it('returns a valid command result even when the peer emits no lifecycle notifications', async () => { - const execution = { commandId: 'command-no-events', result: { kind: 'success' } } - const session = harnessWith({ QUERY_RESULT: JSON.stringify({ kind: 'command', execution }) }).session('main') - await session.query('/compact') - expect(await collect(session.receiveResponse())).toEqual([execution]) -}) - -it('ends a command observation at its own done event when reception happens after another query', async () => { - const harness = harnessWith() - const session = harness.session('main') - await session.query('/compact') - await session.query('later') - await harness.client.request('test/state') - const command = await collect(session.receiveResponse()) - expect(command).toHaveLength(3) - expect(command.at(-1)).toMatchObject({ commandId: 'command-1' }) - expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:later') -}) - -it('keeps a completed command result when the peer disconnects before reception', async () => { - const harness = harnessWith() - const session = harness.session('main') - await session.query('/compact') - await expect(harness.client.request('test/disconnect')).rejects.toThrow(TransportClosedError) - const command = await collect(session.receiveResponse()) - expect(command).toHaveLength(3) - expect(command.at(-1)).toMatchObject({ commandId: 'command-1', result: { kind: 'success', text: 'compacted' } }) -}) - -it('captures notification collection failures before a command receiver consumes the outcome', async () => { - const harness = harnessWith() - const subscribe = harness.client.subscribeSessionTree.bind(harness.client) - vi.spyOn(harness.client, 'subscribeSessionTree').mockImplementation((id) => { - const subscription = subscribe(id) - vi.spyOn(subscription, 'tryNext').mockImplementation(() => { throw new Error('notification collection failed') }) - return subscription - }) - const session = harness.session('main') - await session.query('/compact') - await harness.client.request('test/state') - await expect(collect(session.receiveResponse())).rejects.toThrow('notification collection failed') -}) - -it.each([ - null, - {}, - { kind: 'unknown' }, - { kind: 'prompt' }, - { kind: 'prompt', messageId: '' }, - { kind: 'command', execution: { commandId: 'c', result: { kind: 'error' } } }, -])('rejects malformed routing results on reception: %j', async (result) => { - const session = harnessWith({ QUERY_RESULT: JSON.stringify(result) }).session('main') - await session.query('input') - await expect(collect(session.receiveResponse())).rejects.toThrow(SdkProtocolError) -}) - -it('abandons a receiver returned before its first next without cancelling the server operation', async () => { - const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }) - const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') - const session = harness.session('main') - await session.query('/compact') - const created = subscribed.mock.results[0] - if (created?.type !== 'return') throw new Error('query did not create a subscription') - const close = vi.spyOn(created.value, 'close') - const receiver = session.receiveResponse() - await receiver.return?.() - expect(close).toHaveBeenCalled() - expect(await receiver.next()).toEqual({ done: true, value: undefined }) - expect(await harness.client.request('test/state')).toMatchObject({ waitingCommands: 1 }) - await harness.client.request('test/release') - await session.query('after abandonment') - expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:after abandonment') -}) - -it.each(['hold', '/compact'])('return releases a pending next for %s without cancelling runtime work', async (text) => { - const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }) - const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') - const session = harness.session('main') - await session.query(text) - const waiting = Promise.withResolvers() - if (text === 'hold') { - const created = subscribed.mock.results[0] - if (created?.type !== 'return') throw new Error('query did not create a subscription') - const subscription = created.value - const next = subscription.next.bind(subscription) - // Returning from a yielded buffered frame does not exercise cancellation of an actual read. - vi.spyOn(subscription, 'next').mockImplementation(() => { - const queued = subscription.tryNext() - if (queued !== undefined) return Promise.resolve(queued) - const result = next() - waiting.resolve(undefined) - return result - }) - } - const receiver = session.receiveResponse() - const consuming = collect(receiver) - if (text === 'hold') await waiting.promise - else await harness.client.request('test/state') - await receiver.return?.() - await consuming - expect(await harness.client.request('test/state')).toMatchObject( - text === 'hold' ? { waitingPrompts: 1 } : { waitingCommands: 1 }, - ) - await harness.client.request('test/release') -}) - -it.each(['before receipt', 'receipt', 'after receipt'] as const)( - 'does not emit an acquired %s notification after the receiver closes', async (position) => { - const harness = harnessWith() - const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') - const session = harness.session('main') - await session.query('hold') - const created = subscribed.mock.results[0] - if (created?.type !== 'return') throw new Error('query did not create a subscription') - const next = created.value.next.bind(created.value) - const arrived = Promise.withResolvers() - const release = Promise.withResolvers() - onTestFinished(() => { release.resolve(undefined) }) - vi.spyOn(created.value, 'next').mockImplementation(async () => { - const notification = await next() - const event = notification.params.event as { type: string } | undefined - const matches = position === 'before receipt' - ? notification.method === 'session.event' && event?.type === 'assistant/message' - : position === 'receipt' - ? notification.method === 'session.event' && event?.type === 'agent/inbox/spliced' - : notification.method === 'session.status' && notification.params.status === 'running' - if (matches) { - arrived.resolve(undefined) - await release.promise - } - return notification - }) - const receiver = session.receiveResponse() - const receiving = collect(receiver) - await arrived.promise - const returning = receiver.return?.() - release.resolve(undefined) - const messages = await receiving - if (position === 'after receipt') { - expect(messages).toMatchObject([{ method: 'session.event', params: { event: { type: 'agent/inbox/spliced' } } }]) - } else { - expect(messages).toEqual([]) - } - await expect(returning).resolves.toEqual({ done: true, value: undefined }) - expect(await harness.client.request('test/state')).toMatchObject({ waitingPrompts: 1 }) - await harness.client.request('test/release') - }, -) - -it('closes prompt subscriptions before yielding the final RunResult', async () => { - const harness = harnessWith() - const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') - const session = harness.session('main') - await session.query('complete') - const created = subscribed.mock.results[0] - if (created?.type !== 'return') throw new Error('query did not create a subscription') - const close = vi.spyOn(created.value, 'close') - const receiver = session.receiveResponse() - while (true) { - const item = await receiver.next() - if (item.done) throw new Error('query ended without a RunResult') - if (!('finalResponse' in item.value)) continue - expect(close).toHaveBeenCalled() - expect(item.value.finalResponse).toBe('reply:complete') - break - } - await receiver.return?.() -}) - -it('ends an already-queued read when the receiver closes while yielding idle', async () => { - const harness = harnessWith() - const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') - const session = harness.session('main') - await session.query('complete') - const created = subscribed.mock.results[0] - if (created?.type !== 'return') throw new Error('query did not create a subscription') - const next = created.value.next.bind(created.value) - const receiver = session.receiveResponse() - let returning: ReturnType> | undefined - const observerErrors: unknown[] = [] - vi.spyOn(created.value, 'next').mockImplementation(() => { - const pending = next() - void pending.then((notification) => { - if (notification.method === 'session.status' && notification.params.status === 'idle') { - queueMicrotask(() => { returning = receiver.return?.() }) - } - }, (error: unknown) => { observerErrors.push(error) }) - return pending - }) - // Five admitted notifications precede the final result; queue its read before idle is yielded. - const reads = await Promise.all(Array.from({ length: 6 }, () => receiver.next())) - expect(reads.at(-1)).toEqual({ done: true, value: undefined }) - expect(reads.filter(item => !item.done && 'finalResponse' in item.value)).toEqual([]) - await expect(returning).resolves.toEqual({ done: true, value: undefined }) - expect(observerErrors).toEqual([]) -}) - -it('reports local request timeout from the receiver and leaves the runtime responsive', async () => { - const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }, 50) - const session = harness.session('main') - await session.query('/compact') - await expect(collect(session.receiveResponse())).rejects.toThrow(RequestTimeoutError) - expect(await harness.client.request('test/state', undefined, 5000)).toMatchObject({ waitingCommands: 1 }) -}) - -it('reports runtime loss from the receiver', async () => { - const session = harnessWith({ QUERY_DISCONNECT: '1' }).session('main') - await session.query('disconnect') - await expect(collect(session.receiveResponse())).rejects.toThrow(TransportClosedError) -}) diff --git a/packages/sdk/client/tests/run.spec.ts b/packages/sdk/client/tests/run.spec.ts new file mode 100644 index 0000000000..38cfc2457d --- /dev/null +++ b/packages/sdk/client/tests/run.spec.ts @@ -0,0 +1,144 @@ +/** Unified input receipt and observation over an independently running protocol peer. */ +import { fileURLToPath } from 'node:url' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createProcessDeepSeekHarness } from '../src/api.ts' +import { JsonRpcResponseError, RequestTimeoutError, SdkProtocolError, TransportClosedError } from '../src/index.ts' +import type { CommandExecution, HarnessNotification, RunResult } from '../src/types.ts' + +function harnessWith(environment: Record = {}, requestTimeoutMs?: number) { + const harness = createProcessDeepSeekHarness({ + command: process.execPath, + args: [fileURLToPath(new URL('./fixtures/query.ts', import.meta.url))], + environment: () => ({ ...process.env, ...environment }), + description: 'query protocol fixture', + initializeTimeoutMs: 5000, + ...requestTimeoutMs === undefined ? {} : { requestTimeoutMs }, + }) + onTestFinished(() => harness.close()) + return harness +} + +function activity(result: RunResult | CommandExecution): RunResult { + if ('commandId' in result) throw new Error('expected ordinary prompt activity') + return result +} + +it.each(['session', 'harness'])('runs a native command through the %s entry without waiting for idle', async (entry) => { + const harness = harnessWith() + const notifications: HarnessNotification[] = [] + const onNotification = (notification: HarnessNotification): void => { notifications.push(notification) } + const result = entry === 'session' + ? await harness.session('main').run('/compact', { onNotification }) + : await harness.run('/compact', { sessionId: 'main', onNotification }) + expect(result).toEqual({ commandId: 'command-1', result: { kind: 'success', text: 'compacted', sourceEventSeq: 0 } }) + expect(notifications.map(notification => (notification.params.event as { type: string }).type)) + .toEqual(['command/run', 'command/done']) +}) + +it('preserves prompt text and observes its receipt-to-idle activity', async () => { + const notifications: HarnessNotification[] = [] + const result = activity(await harnessWith().session('main').run(' first\n', { + onNotification: (notification) => { notifications.push(notification) }, + })) + expect(result.finalResponse).toBe('reply: first\n') + expect(result.notifications).toEqual(notifications) + expect(result.events.some(event => event.type === 'assistant/message' && event.data.message.content.some(block => + block.type === 'text' && block.text === 'before receipt'))).toBe(false) +}) + +it('steers ordinary text when delivery requests the next step', async () => { + const result = activity(await harnessWith().session('main').run('next', { delivery: 'steer' })) + expect(result.notifications[0]).toMatchObject({ params: { event: { + type: 'agent/inbox/spliced', data: { target: 'next-step' }, + } } }) +}) + +it('keeps root result events separate from descendant notifications', async () => { + const result = activity(await harnessWith().session('main').run('with-child')) + expect(result.finalResponse).toBe('reply:with-child') + expect(result.notifications.some(notification => notification.params.sessionId === 'child')).toBe(true) + expect(result.notifications.some(notification => notification.params.sessionId === 'unrelated')).toBe(false) + expect(result.events.filter(event => event.type === 'assistant/message')).toHaveLength(1) +}) + +it('retains independent results for concurrent calls on one Session', async () => { + const session = harnessWith().session('main') + const results = await Promise.all([session.run('first'), session.run('second')]) + expect(results.map(result => activity(result).finalResponse)).toEqual(['reply:first', 'reply:second']) +}) + +it('returns a busy command result while another run waits for model activity', async () => { + const harness = harnessWith() + const session = harness.session('main') + const entered = Promise.withResolvers() + const running = session.run('hold', { onNotification(notification) { + if (notification.method === 'session.status' && notification.params.status === 'running') entered.resolve(undefined) + } }) + await entered.promise + expect(await session.run('/compact')).toEqual({ commandId: 'command-2', result: { kind: 'error', text: 'busy' } }) + await harness.client.request('test/release') + expect(activity(await running).finalResponse).toBe('reply:hold') +}) + +it.each(['/model', ' /compact extra '])('preserves unsupported command input as text: %s', async (text) => { + expect(activity(await harnessWith().session('main').run(text)).finalResponse).toBe(`reply:${text}`) +}) + +it('returns a command result without lifecycle notifications', async () => { + const execution = { commandId: 'command-no-events', result: { kind: 'success' } } + const notifications: HarnessNotification[] = [] + const result = await harnessWith({ QUERY_RESULT: JSON.stringify({ kind: 'command', execution }) }).session('main').run('/compact', { + onNotification: (notification) => { notifications.push(notification) }, + }) + expect(result).toEqual(execution) + expect(notifications).toEqual([]) +}) + +it.each(['ordinary', '/compact'])('releases its subscription after an observer rejects %s', async (text) => { + const harness = harnessWith() + const subscribe = harness.client.subscribeSessionTree.bind(harness.client) + const closes: ReturnType[] = [] + vi.spyOn(harness.client, 'subscribeSessionTree').mockImplementation((id) => { + const subscription = subscribe(id) + closes.push(vi.spyOn(subscription, 'close')) + return subscription + }) + await expect(harness.session('main').run(text, { onNotification() { throw new Error('observer failed') } })) + .rejects.toThrow('observer failed') + expect(closes[0]).toHaveBeenCalled() + expect(activity(await harness.session('main').run('after')).finalResponse).toBe('reply:after') +}) + +it('propagates routing rejection without retrying as a prompt', async () => { + const harness = harnessWith({ QUERY_ERROR_TEXT: 'reject this input' }) + await expect(harness.session('main').run('reject this input')).rejects.toThrow(JsonRpcResponseError) + expect(await harness.client.request('test/state')).toMatchObject({ methods: ['initialize', 'session/query', 'test/state'] }) +}) + +it.each([ + null, + {}, + { kind: 'unknown' }, + { kind: 'prompt' }, + { kind: 'prompt', messageId: '' }, + { kind: 'command', execution: { commandId: 'c', result: { kind: 'error' } } }, +])('rejects malformed routing results: %j', async (result) => { + await expect(harnessWith({ QUERY_RESULT: JSON.stringify(result) }).session('main').run('input')) + .rejects.toThrow(SdkProtocolError) +}) + +it('reports command timeout without cancelling the runtime operation', async () => { + const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }, 50) + await expect(harness.session('main').run('/compact')).rejects.toThrow(RequestTimeoutError) + expect(await harness.client.request('test/state', undefined, 5000)).toMatchObject({ waitingCommands: 1 }) + await harness.client.request('test/release', undefined, 5000) +}) + +it('reports runtime loss from run', async () => { + await expect(harnessWith({ QUERY_DISCONNECT: '1' }).session('main').run('disconnect')).rejects.toThrow(TransportClosedError) +}) + +it('rejects steer delivery for literal content blocks', async () => { + await expect(harnessWith().session('main').run([{ type: 'text', text: 'literal' }], { delivery: 'steer' })) + .rejects.toThrow('content-block input requires queue delivery') +}) diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 5ed81e386e..879b764d48 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -70,7 +70,7 @@ describe('DeepSeekHarness', () => { expect(await harness.client.executeCommand(session.id, ' /compact ')).toEqual({ commandId: 'command-1', result: { kind: 'success', text: ' /compact ' }, }) - expect((await session.run('/compact')).finalResponse).toBe('hello from fake runtime') + expect(await session.run('/compact')).toMatchObject({ commandId: 'command-1', result: { kind: 'success' } }) }) it.each([ @@ -113,7 +113,7 @@ describe('DeepSeekHarness', () => { const harness = harnessWith() const session = await harness.resumeSession('saved-session') expect(session.id).toBe('saved-session') - expect((await session.run('continue')).sessionId).toBe('saved-session') + expect((await session.run([{ type: 'text', text: 'continue' }])).sessionId).toBe('saved-session') }) it('propagates resume failures', async () => { @@ -186,7 +186,7 @@ describe('DeepSeekHarness', () => { }, } as unknown as DeepSeekHarness - const result = await new HarnessSession(harness, 'owned').run('go') + const result = await new HarnessSession(harness, 'owned').run([{ type: 'text', text: 'go' }]) expect(result.notifications.map(notification => notification.method)) .toEqual(['session.event', 'session.status']) @@ -278,7 +278,7 @@ describe('DeepSeekHarness', () => { }, } as unknown as DeepSeekHarness - const result = await new HarnessSession(harness, 'owned').run('go') + const result = await new HarnessSession(harness, 'owned').run([{ type: 'text', text: 'go' }]) expect(result.events).toEqual([receipt, nativeResult, ptcStart, ptcResult]) expect(result.events.find(event => event.type === 'tool/ptc-dispatch')?.data.meta) @@ -292,7 +292,7 @@ describe('DeepSeekHarness', () => { it('runs a turn end to end and reuses the runtime across sessions', async () => { const harness = harnessWith({ FAKE_TEXT: 'turn answer' }) - const first = await harness.run('say hi') + const first = await harness.run([{ type: 'text', text: 'say hi' }]) expect(first.finalResponse).toBe('turn answer') expect(first.events.map(event => event.type)).toEqual([ 'agent/inbox/spliced', 'turn/start', 'assistant/message', 'turn/end', @@ -318,7 +318,7 @@ describe('DeepSeekHarness', () => { it('keeps events root-scoped while streaming notifications for the session tree', async () => { const harness = harnessWith({ FAKE_SUBAGENT: '1' }) const seen: HarnessNotification[] = [] - const result = await harness.run('delegate', { + const result = await harness.run([{ type: 'text', text: 'delegate' }], { sessionId: 'parent-1', onNotification: (n) => { seen.push(n) }, }) @@ -348,8 +348,8 @@ describe('DeepSeekHarness', () => { approvalRequests: true, }) cleanups.push(() => harness.close()) - await harness.run('one') - await harness.run('two') + await harness.run([{ type: 'text', text: 'one' }]) + await harness.run([{ type: 'text', text: 'two' }]) await harness.close() const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object) expect(records).toEqual([{ @@ -390,14 +390,14 @@ describe('DeepSeekHarness', () => { it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => { const harness = harnessWith({ FAKE_INIT_ERROR: '1' }) - const failure = await harness.run('boom').then( + const failure = await harness.run([{ type: 'text', text: 'boom' }]).then( () => { throw new Error('run unexpectedly succeeded') }, (error: unknown) => error, ) expect(failure).toBeInstanceOf(JsonRpcResponseError) expect(failure).toMatchObject({ code: 7, message: 'scripted init failure', data: { hint: 'fake' } }) // The failed handshake reset lets a later start retry instead of wedging. - await expect(harness.run('later')).rejects.toThrow() + await expect(harness.run([{ type: 'text', text: 'later' }])).rejects.toThrow() }) it('preserves both initialize and SDK-owned cleanup failures', async () => { @@ -450,17 +450,17 @@ describe('DeepSeekHarness', () => { // First start: the scripted runtime fails the handshake and is reaped. await expect(harness.start()).rejects.toThrow('scripted first-boot failure') // Retry spawns a NEW subprocess through a fresh client (close is permanent). - const result = await harness.run('again') + const result = await harness.run([{ type: 'text', text: 'again' }]) expect(harness.client).not.toBe(firstClient) expect(result.finalResponse).toBe('second boot answer') await harness.close() // close() is terminal: a handshake failure after it must not respawn. - await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError) + await expect(harness.run([{ type: 'text', text: 'after-close' }])).rejects.toThrow(TransportClosedError) }) it('rejects a malformed initialize result as a protocol error', async () => { const harness = harnessWith({ FAKE_MALFORMED: '1' }) - await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError) + await expect(harness.run([{ type: 'text', text: 'bad' }])).rejects.toThrow(SdkProtocolError) }) it('supports await using disposal', async () => { @@ -468,11 +468,11 @@ describe('DeepSeekHarness', () => { { await using harness = createProcessDeepSeekHarness(fakeLaunch()) captured = harness - const result = await harness.run('scoped') + const result = await harness.run([{ type: 'text', text: 'scoped' }]) expect(result.finalResponse).toBe('hello from fake runtime') } // After scope exit the runtime is closed: reuse fails loudly. - await expect(captured.run('after')).rejects.toThrow(TransportClosedError) + await expect(captured.run([{ type: 'text', text: 'after' }])).rejects.toThrow(TransportClosedError) }) it('constructs the public dsh-backed client lazily', async () => { @@ -754,27 +754,27 @@ describe('HarnessClient', () => { describe('wire payload validation', () => { it('rejects a non-object session.event envelope as a protocol error', async () => { const harness = harnessWith({ FAKE_MALFORMED_EVENT: '1' }) - await expect(harness.run('bad-event')).rejects.toThrow(SdkProtocolError) + await expect(harness.run([{ type: 'text', text: 'bad-event' }])).rejects.toThrow(SdkProtocolError) }) it('rejects an assistant/message without a content array as a protocol error', async () => { const harness = harnessWith({ FAKE_MALFORMED_MESSAGE: '1' }) - await expect(harness.run('bad-message')).rejects.toThrow(SdkProtocolError) + await expect(harness.run([{ type: 'text', text: 'bad-message' }])).rejects.toThrow(SdkProtocolError) }) it('rejects an assistant/message without a data member as a protocol error', async () => { const harness = harnessWith({ FAKE_MESSAGE_WITHOUT_DATA: '1' }) - await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError) + await expect(harness.run([{ type: 'text', text: 'no-data' }])).rejects.toThrow(SdkProtocolError) }) it.each(['1', 'aborted', 'abort-unknown', 'hook', 'no-data'])('rejects malformed turn/end input %s as a protocol error', async (mode) => { const harness = harnessWith({ FAKE_MALFORMED_REASON: mode }) - await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError) + await expect(harness.run([{ type: 'text', text: 'bad-reason' }])).rejects.toThrow(SdkProtocolError) }) it('accepts the complete hook cancellation cause', async () => { const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'hook' }) - const result = await harness.run('hook-abort') + const result = await harness.run([{ type: 'text', text: 'hook-abort' }]) const end = result.events.findLast(event => event.type === 'turn/end') expect(end?.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'hook', reason: 'scripted hook abort' } }) }) @@ -837,7 +837,7 @@ describe('server-to-client SDK requests', () => { describe('SDK cancellation reason projection', () => { it.each(['user', 'parent', 'disposed', 'legacy'])('retains the %s cancellation cause', async (kind) => { const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: kind }) - const result = await harness.run('cancelled') + const result = await harness.run([{ type: 'text', text: 'cancelled' }]) expect(result.events.findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind } }) }) diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index b397f6fab4..0bd9f995ab 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package: - en: d8b2dbbba9fd4ba4 - zh: a50fbb7e2dc73043 + en: 805a15a503ad8271 + zh: f254bc6f0d1644b2 /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 0ee4f737b1..cddf844e73 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -35,7 +35,7 @@ The plugin publishes the `sdkSessionActivation` readiness marker once its remind -`session/query` is the unified text entry. Ordinary text retains its original content and uses the selected queue/steer operation. The full SDK recognizes bare `/compact`, `/permission ` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`, including Desktop aliases bound to their current definitions. Bare `/permission` or `/权限`, `/compact extra`, and all other input including `/model` remain ordinary prompts. A missing command also preserves the prompt path; a selected handler's failure never falls back. An unknown preset is a native command error. Inputs are text-only; `session/prompt` retains its content-block API. +`session/query` is the wire entry used by the high-level SDK `run()` for text. Ordinary text retains its original content and uses the selected queue/steer operation. The full SDK recognizes bare `/compact`, `/permission ` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`, including Desktop aliases bound to their current definitions. Bare `/permission` or `/权限`, `/compact extra`, and all other input including `/model` remain ordinary prompts. A missing command also preserves the prompt path; a selected handler's failure never falls back. An unknown preset is a native command error. Inputs are text-only; `session/prompt` retains its content-block API. `session/command` accepts `{ sessionId, line }` for the supported command names above, requires a non-blank permission preset, and lets native handlers validate other arguments. The full sdk profile composes these native handlers; profiles without the requested command reject it. The server resolves its own live Agent and calls `commands.execute()` without queueing a prompt or waiting for idle. Compaction owns its auxiliary model request, history replacement and checkpoint, and refuses a busy Agent. Permission changes retain their native state and notification behavior. Shutdown cancels pending commands and drains SDK command waiters before releasing Agents. Timeout is not rollback; clients must not retry automatically. See the [protocol](../protocol/README.md) for results and errors. diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 29f1c77ea4..33d8709516 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -35,11 +35,11 @@ kind: "package-reference" -`session/query` 是统一文本入口。普通文本保留原文,使用选定的 queue/steer 操作。完整 SDK 识别裸 `/compact`、带非空 preset 的 `/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,包括绑定到当前定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/compact extra` 以及 `/model` 等其余输入仍是普通提示词。命令缺失时也保留提示词路径;选中的 handler 失败后绝不回退。未知 preset 是原生命令错误。此入口仅接受文本,`session/prompt` 保留内容块 API。 +`session/query` 是高层 SDK `run()` 发送文本时使用的协议入口。普通文本保留原文,使用选定的 queue/steer 操作。完整 SDK 识别裸 `/compact`、带非空 preset 的 `/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,包括绑定到当前定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/compact extra` 以及 `/model` 等其余输入仍是普通提示词。命令缺失时也保留提示词路径;选中的 handler 失败后绝不回退。未知 preset 是原生命令错误。此入口仅接受文本,`session/prompt` 保留内容块 API。 `session/command` 接受上述命令名的 `{ sessionId, line }`,要求 permission preset 非空,其余参数由原生 handler 校验。完整 sdk profile 组成这些原生 handler;缺少所请求命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。压缩负责其辅助模型请求、历史替换和 checkpoint,并拒绝忙碌 Agent。权限变更保留原生状态和通知行为。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不等于回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 -权限命令只修改接收命令的 Session。后续 query 和恢复的 Session 保留其持久化权限事实;其他 Session 保留各自状态,新 Session 使用配置的默认值。调用方的启动配置和已保存的用户默认值均不会被重写。模型选择不是 SDK slash 命令;恢复时的模型覆盖仍按上文描述的独立行为执行。 +权限命令只修改接收命令的 Session。后续输入和恢复的 Session 保留其持久化权限事实;其他 Session 保留各自状态,新 Session 使用配置的默认值。调用方的启动配置和已保存的用户默认值均不会被重写。模型选择不是 SDK slash 命令;恢复时的模型覆盖仍按上文描述的独立行为执行。 `/plan` 和 `/plan off` 只改变原生计划状态。带正文时由 handler 通过 steer 提交;命令完成不等待后续模型活动或审查,`session/wait` 可观察后续 Session 工作。问答响应保留批准和反馈语义;`{ cancelled: true }` 转为原生 `ASK_CANCELLED`,使关闭计划审查结束轮次而不退出计划模式。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index fbfffcb7ec..209636714a 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -9,11 +9,11 @@ en: ddf4dabea526cef1 zh: 6e31cab10d2f3ed8 /deepseek-harness-python-sdk/start-a-runtime: - en: c4c30913fe2642ef - zh: 0ed42bca37fba60b -/deepseek-harness-python-sdk/query-and-response: - en: 7cc5feefefd05142 - zh: ea346bf354ec266d + en: ac793c588b13ce87 + zh: 502015cda46de3ac +/deepseek-harness-python-sdk/run-text-and-commands: + en: c781ae575e6be4d2 + zh: cc802c264f8a7a99 /deepseek-harness-python-sdk/client-approvals: en: fc69be1beb8abff1 zh: 91b56781931a6f68 diff --git a/python/sdk/README.md b/python/sdk/README.md index ea41fe307f..20aff3f5a6 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -33,7 +33,7 @@ The Python SDK has no separate application entrypoint. It launches the bundled ` Every launch requires an explicit Harness home. Pass `dsh_home` or provide a non-empty `DSH_HOME` in the child environment. The SDK deliberately never discovers `~/.dsh`. ```py -from deepseek_harness import DeepSeekHarness +from deepseek_harness import DeepSeekHarness, RunResult with DeepSeekHarness( dsh_home="/absolute/path/to/isolated-dsh-home", @@ -45,10 +45,10 @@ with DeepSeekHarness( ) as harness: result = harness.run("Say hi.", session_id="example-001") -print(result.final_response) +print(result.final_response if isinstance(result, RunResult) else result.result.text) ``` -`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. +`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary activity remains unbounded after its protocol response, whose wait can be limited with `request_timeout_seconds`. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. `harness.resume_session(saved_session_id)` restores persisted history and returns a `Session` ready for `run()`. Save the original session id and reuse its `dsh_home` and workspace. Restoration preserves the stored directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions raise without creating a replacement. `start_session(id)` only opens a lazy handle; it does not request persisted restoration. This API does not restore shell, browser, or other external processes. @@ -58,22 +58,24 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: result = session.run("Continue the task.") ``` - -## Query and response + +## Run text and commands -Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The runtime recognizes the [supported command forms and Desktop aliases](../../packages/sdk/server/README.md#command-input); unrecognized input remains ordinary text. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command. +`session.run(input, on_notification=..., delivery="queue")` is the high-level input entry. Non-blank strings use the runtime's [supported command forms and Desktop aliases](../../packages/sdk/server/README.md#command-input); unrecognized input remains ordinary text. `delivery="steer"` targets the next available step for ordinary text. Command admission and timing remain owned by the native handler. -`receive_response()` claims the next query in send order. It first waits for the routing response, then yields `Notification` objects followed by one terminal value: `RunResult` for the prompt's inbox-receipt-to-idle interval, or `CommandExecution` after the command settles. Notifications can accumulate before receiving begins. Separate receivers can observe their queries concurrently, and another `query()` can be sent while a receiver waits. Close an unfinished iterator to release only its local observation; this does not cancel or roll back runtime work. Resources are released before yielding the terminal value. +The call returns `RunResult` for an ordinary prompt's inbox-receipt-to-idle interval, or `CommandExecution` after a command settles. Use `isinstance(result, RunResult)` before reading `final_response`; otherwise inspect the command's `result.kind`. A command retains its `command_id` and optional `source_event_seq`, rather than selecting an earlier assistant reply. -`CommandExecution` contains `command_id` and a `result` discriminated by `kind`. Success may include `text` and `source_event_seq`; an error includes `text`. Handler errors return `kind="error"`; routing, protocol and transport failures raise during receiving. Sending failures raise from `query()`. `request_timeout_seconds` bounds waiting for the routing response, not later prompt activity; an already-arrived response remains receivable after that deadline. +Content-block arrays stay literal queued prompts and return `RunResult`. For example, `session.run([{"type": "text", "text": "/compact"}])` sends that text to the model, while `session.run("/compact")` invokes native compaction. Content blocks require queue delivery; a steer request raises `ValueError` before sending. -The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` executes supported command names without prompt fallback; it requires a non-blank permission preset. See the [compaction example](examples/README.md#compact-a-full-sdk-session). +Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command notifications are delivered after the command response, ending at its own `command/done`. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity. -`/permission ` delegates to the native permission handler. An unknown preset returns `CommandExecution.result.kind="error"` without changing permission state. A successful change belongs to that Session: later queries and restoration retain it, other Sessions retain their own state, and new Sessions use configured defaults. `DeepSeekHarnessConfig` remains startup input, not a shared live state mirror; the command does not rewrite it or saved user defaults. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions). +The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` executes supported command names without prompt fallback. See the [compaction example](examples/README.md#compact-a-full-sdk-session). -`/plan` enters plan mode, `/plan off` leaves, and `/plan ` enters then submits the trimmed message through native steer. Its `CommandExecution` confirms command completion, not completion of planning or review. To collect a normal activity result, enter with `/plan` then submit a separate ordinary query. For combined input, subscribe before sending and use `session/wait` while keeping the question handler alive, as in the [plan example](examples/README.md#review-a-plan). Plan mode changes guidance and progressively admits `exit_plan_mode`; it does not change permission settings. The default guidance prohibits file edits, including plan documents, while execution remains governed by the sandbox. +`/permission ` changes only the receiving Session. An unknown preset returns a command error without changing policy. Later input and restoration retain successful changes; other Sessions and the caller's `DeepSeekHarnessConfig` keep their own values. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions). -`/goal` shows the current goal; `/goal `, `edit `, `pause`, `resume`, and `clear` use the native goal handler. Creation and resume can start automatic rounds after the command returns. Subscribe before sending; observe terminal `goal/change` operations, then call `session/wait` for any closing model reply. Idle between rounds is not goal completion. A restored active goal stays disarmed until explicit resume. Goal commands preserve permission settings and startup configuration. See the [goal example](examples/README.md#run-a-goal). +`/plan` enters plan mode, `/plan off` leaves, and `/plan ` enters then submits the trimmed message through native steer. Its `CommandExecution` confirms command completion, not completion of planning or review. Enter with `/plan` then run ordinary text when a `RunResult` is needed. For combined input, subscribe before `run()` and use `session/wait` while keeping the question handler alive, as in the [plan example](examples/README.md#review-a-plan). Plan guidance and `exit_plan_mode` follow their native lifecycle without changing permission settings. + +`/goal` shows the current goal; `/goal `, `edit `, `pause`, `resume`, and `clear` use the native handler. Creation and resume can start automatic rounds after `run()` returns. Subscribe before sending, observe terminal `goal/change` operations, then call `session/wait` for any closing model reply. Idle between rounds is not goal completion. A restored active goal stays disarmed until explicit resume. See the [goal example](examples/README.md#run-a-goal). ## Client approvals diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index c040fe75a2..06027da87a 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -33,7 +33,7 @@ Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的 每次启动都必须显式指定 Harness home。请传入 `dsh_home`,或在子进程环境中提供非空的 `DSH_HOME`。SDK 刻意不会发现 `~/.dsh`。 ```py -from deepseek_harness import DeepSeekHarness +from deepseek_harness import DeepSeekHarness, RunResult with DeepSeekHarness( dsh_home="/absolute/path/to/isolated-dsh-home", @@ -45,10 +45,10 @@ with DeepSeekHarness( ) as harness: result = harness.run("Say hi.", session_id="example-001") -print(result.final_response) +print(result.final_response if isinstance(result, RunResult) else result.result.text) ``` -`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 +`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通活动在协议响应后仍不设时限;`request_timeout_seconds` 可以限制协议响应的等待。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 `harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。 @@ -58,22 +58,24 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: result = session.run("Continue the task.") ``` - -## 查询与接收 + +## 运行文本与命令 -调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。运行时识别[支持的命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input);未识别的输入保持普通文本。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。 +`session.run(input, on_notification=..., delivery="queue")` 是高层输入入口。非空白字符串使用运行时的[命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input)识别规则;未识别的输入保持普通文本。对普通文本,`delivery="steer"` 指向下一个可用步骤。命令的接纳规则与执行时序仍由原生处理器负责。 -`receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。 +普通提示词返回其入队回执到下一次 idle 区间的 `RunResult`,命令则在执行结束后返回 `CommandExecution`。读取 `final_response` 前使用 `isinstance(result, RunResult)` 检查类型,否则检查命令的 `result.kind`。命令保留自己的 `command_id` 和可选 `source_event_seq`,不会选取更早的助手回复作为结果。 -`CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。 +内容块数组保持原样排队输入,并返回 `RunResult`。例如,`session.run([{"type": "text", "text": "/compact"}])` 将该文本发送给模型,而 `session.run("/compact")` 调用原生压缩。内容块要求 queue 投递;请求 steer 会在发送前抛出 `ValueError`。 -完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 执行支持的命令名,不回退为提示词;permission preset 必须非空。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 +每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令通知在命令响应到达后交付,止于其自身的 `command/done`。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。 -`/permission ` 委托给原生权限 handler。未知 preset 返回 `CommandExecution.result.kind="error"`,不改变权限状态。成功的变更属于该 Session:后续 query 和恢复保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。`DeepSeekHarnessConfig` 仍是启动输入,不是共享的实时状态镜像;命令不重写它或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外模型 step。另见[权限示例](examples/README.zh.md#change-session-permissions)。 +默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 严格执行支持的命令名,不回退为提示词。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 -`/plan` 进入计划模式,`/plan off` 退出,`/plan ` 进入后通过原生 steer 提交去除首尾空白的正文。返回的 `CommandExecution` 只表示命令完成,不表示规划或审查结束。需要普通活动结果时,可以先用 `/plan` 进入,再单独提交普通 query。组合输入则应在发送前订阅,并在保持问答处理器运行的同时使用 `session/wait`,见[计划示例](examples/README.zh.md#review-a-plan)。计划模式改变指导并渐进披露 `exit_plan_mode`,不修改权限设置。默认指导禁止编辑文件,包括计划文档;实际执行仍由沙箱控制。 +`/permission ` 只修改接收命令的 Session。未知 preset 返回命令错误,不改变策略。后续输入与恢复保留成功的修改,其他 Session 和调用方的 `DeepSeekHarnessConfig` 保留自己的值。运行中的审批策略变化可以注入通知并触发下一模型步骤。见[权限示例](examples/README.zh.md#change-session-permissions)。 -`/goal` 显示当前目标;`/goal `、`edit `、`pause`、`resume` 和 `clear` 使用原生目标 handler。创建和恢复可在命令返回后启动自动轮次。发送前应订阅通知,观察终态 `goal/change` 操作,再调用 `session/wait` 等待可能的模型收尾回复。轮次之间的 idle 不代表目标完成。恢复的活动目标保持 disarmed,直到显式恢复。目标命令保留权限设置和启动配置。另见[目标示例](examples/README.zh.md#run-a-goal)。 +`/plan` 进入计划模式,`/plan off` 退出,`/plan ` 进入后通过原生 steer 提交去除首尾空白的正文。其 `CommandExecution` 确认命令执行结束,不代表计划生成或审阅结束。需要 `RunResult` 时,先用 `/plan` 进入,再运行普通文本。组合输入应在 `run()` 前订阅,并保持问答处理器存活,通过 `session/wait` 观察后续工作,见[计划示例](examples/README.zh.md#review-a-plan)。计划指导和 `exit_plan_mode` 遵循原生生命周期,不修改权限设置。 + +`/goal` 查看当前目标;`/goal `、`edit `、`pause`、`resume` 和 `clear` 使用原生处理器。创建和恢复可以在 `run()` 返回后启动自动轮次。发送前订阅,观察终态 `goal/change` 操作后,再调用 `session/wait` 等待可能的模型收尾。轮次之间的 idle 不代表目标完成。恢复的活动目标在显式 resume 前保持 disarmed。见[目标示例](examples/README.zh.md#run-a-goal)。 ## 客户端审批 diff --git a/python/sdk/examples/README.i18n.yaml b/python/sdk/examples/README.i18n.yaml index f97e3f117c..87c7e2c8bb 100644 --- a/python/sdk/examples/README.i18n.yaml +++ b/python/sdk/examples/README.i18n.yaml @@ -6,17 +6,17 @@ en: c97ffc5ce0f7db05 zh: bdd91202075498a3 /python-sdk-example/run-the-minimal-agent: - en: 4b79fd6576e2759d + en: ac09bb6bc3ab76a0 zh: 6258540acaf4bc37 /python-sdk-example/compact-a-full-sdk-session: - en: ad4d67225570ed01 - zh: 0d59f6dac40129d6 + en: 7749773f97211ac1 + zh: 594348ae66a2ef6c /python-sdk-example/change-session-permissions: - en: 3471642d8444d0e9 - zh: 7831f28295a6e68a + en: 1fe5e06c6842a53b + zh: ff0836d8cffaeef7 /python-sdk-example/review-a-plan: - en: 46758c022608af0f - zh: 09b5a59176c11176 + en: 2596f68bb55082c8 + zh: 1e40ca75e1a0f9b9 /python-sdk-example/run-a-goal: en: ae73e9fd9d402047 zh: f9694bc6a6e12929 diff --git a/python/sdk/examples/README.md b/python/sdk/examples/README.md index 1e3c7ad402..5a08a0583e 100644 --- a/python/sdk/examples/README.md +++ b/python/sdk/examples/README.md @@ -25,14 +25,14 @@ The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../packages/bundle/sdk - `read_image` for PNG/JPEG/WebP/GIF files, with an image-capable model - `working_directory` to inspect or change the Session directory -The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, text file tools, local instruction discovery, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. [Automatic context compaction](../../../packages/bundle/sdk-minimal/README.md#model-experience) summarizes older history through an additional model request while preserving the complete log. SDK prompt text does not execute slash commands. +The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, text file tools, local instruction discovery, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. [Automatic context compaction](../../../packages/bundle/sdk-minimal/README.md#model-experience) summarizes older history through an additional model request while preserving the complete log. This minimal composition leaves unsupported slash input on the ordinary prompt path. The persistent PTY can modify any path available to the runtime process, so use a disposable checkout or container. ## Compact a full SDK Session -The [compaction example](compact.py) uses the full `sdk` profile. It reads `README.md` from the selected workspace into a `Session.query()` prompt, sends `/compact` through the same method, then asks a follow-up using the retained context. Each query is paired with `Session.receive_response()`. It uses the same model credentials and `DSH_MODEL` default as the minimal example, and requires an explicit home: +The [compaction example](compact.py) uses the full `sdk` profile. It reads `README.md` from the selected workspace into a `Session.run()` prompt, executes `/compact` through the same method, then asks a follow-up using the retained context. It uses the same model credentials and `DSH_MODEL` default as the minimal example, and requires an explicit home: ```sh python python/sdk/examples/compact.py \ @@ -40,12 +40,12 @@ python python/sdk/examples/compact.py \ --workspace /absolute/path/to/disposable-workspace ``` -The script consumes notifications and prints the command id and outcome separately from the model's responses. A failed turn or command stops the example. See [Query and response](../README.md#query-and-response) for completion and error behavior; `minimal.py` retains its `run()` entry and does not enable command execution. +The script prints command ids and outcomes separately from model responses. A failed turn or command stops the example. See [Run text and commands](../README.md#run-text-and-commands) for completion and error behavior; `minimal.py` uses the same `run()` entry, with capabilities supplied by its selected profile. ## Change Session permissions -The [permission example](permission.py) starts the full `sdk` profile with a `workspace-write` default, sends `/permission `, then submits an ordinary query on the same Session. Supply the preset explicitly: +The [permission example](permission.py) starts the full `sdk` profile with a `workspace-write` default, sends `/permission `, then runs ordinary text on the same Session. Supply the preset explicitly: ```sh python python/sdk/examples/permission.py \ @@ -54,12 +54,12 @@ python python/sdk/examples/permission.py \ --preset read-only ``` -The command changes the Session's policy while the printed launch setting remains `workspace-write`. The later query uses the Session's current policy. Bare `/permission` is ordinary text, and unknown presets produce a command error. See [Query and response](../README.md#query-and-response) for result and state semantics. +The command changes the Session's policy while the printed launch setting remains `workspace-write`. The later input uses the Session's current policy. Bare `/permission` is ordinary text, and unknown presets produce a command error. See [Run text and commands](../README.md#run-text-and-commands) for result and state semantics. ## Review a plan -The [plan example](plan.py) sends `/plan ` through query and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; EOF dismisses without approving. Approval allows implementation from a later model step, so choose an isolated workspace. +The [plan example](plan.py) sends `/plan ` through `run()` and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; EOF dismisses without approving. Approval allows implementation from a later model step, so choose an isolated workspace. ```sh python python/sdk/examples/plan.py \ diff --git a/python/sdk/examples/README.zh.md b/python/sdk/examples/README.zh.md index 8b05b4806d..f01eb3d591 100644 --- a/python/sdk/examples/README.zh.md +++ b/python/sdk/examples/README.zh.md @@ -32,7 +32,7 @@ python python/sdk/examples/minimal.py \ ## 压缩完整 SDK Session -[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.query()` 输入,通过同一方法发送 `/compact`,随后使用保留的上下文继续提问。每次 query 都配对调用 `Session.receive_response()`。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home: +[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.run()` 输入,通过同一方法执行 `/compact`,随后使用保留的上下文继续提问。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home: ```sh python python/sdk/examples/compact.py \ @@ -40,12 +40,12 @@ python python/sdk/examples/compact.py \ --workspace /absolute/path/to/disposable-workspace ``` -脚本消费通知,并将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[查询与接收](../README.zh.md#query-and-response);`minimal.py` 保留其 `run()` 入口,不启用命令执行。 +脚本将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[运行文本与命令](../README.zh.md#run-text-and-commands);`minimal.py` 使用同一个 `run()` 入口,能力由所选 profile 提供。 ## 修改 Session 权限 -[权限示例](permission.py)以 `workspace-write` 为默认值启动完整 `sdk` profile,发送 `/permission `,随后在同一个 Session 中提交普通 query。请显式指定 preset: +[权限示例](permission.py)以 `workspace-write` 为默认值启动完整 `sdk` profile,发送 `/permission `,随后在同一个 Session 中运行普通文本。请显式指定 preset: ```sh python python/sdk/examples/permission.py \ @@ -54,12 +54,12 @@ python python/sdk/examples/permission.py \ --preset read-only ``` -命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续 query 使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[查询与接收](../README.zh.md#query-and-response)。 +命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续输入使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[运行文本与命令](../README.zh.md#run-text-and-commands)。 ## 审查计划 -[计划示例](plan.py) 通过 query 发送 `/plan `,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。 +[计划示例](plan.py) 通过 `run()` 发送 `/plan `,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。 ```sh python python/sdk/examples/plan.py \ diff --git a/python/sdk/examples/compact.py b/python/sdk/examples/compact.py index 70831b7d8f..7b3cacc49d 100644 --- a/python/sdk/examples/compact.py +++ b/python/sdk/examples/compact.py @@ -7,22 +7,21 @@ import argparse import os from pathlib import Path -from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session +from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult -def receive(session: Session) -> None: - """Consume notifications and print the query's command or activity result.""" - for result in session.receive_response(): - if isinstance(result, RunResult): - if result.finish_reason != "completed": - raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}") - print(result.final_response) - elif isinstance(result, CommandExecution): - print(f"Command {result.command_id}: {result.result.kind}") - if result.result.kind == "error": - raise RuntimeError(result.result.text) - if result.result.text is not None: - print(result.result.text) +def display(result: RunResult | CommandExecution) -> None: + """Print the input's result and stop on a reported failure.""" + if isinstance(result, CommandExecution): + print(f"Command {result.command_id}: {result.result.kind}") + if result.result.kind == "error": + raise RuntimeError(result.result.text) + if result.result.text is not None: + print(result.result.text) + else: + if result.finish_reason != "completed": + raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}") + print(result.final_response) def main() -> None: @@ -50,15 +49,12 @@ def main() -> None: max_tokens=args.max_tokens, ) as harness: session = harness.start_session(args.session_id) - session.query( + display(session.run( "Read this project README and summarize its purpose and development commands without using tools.\n\n" + readme.read_text(encoding="utf-8") - ) - receive(session) - session.query("/compact") - receive(session) - session.query("What are the project's purpose and development commands? Use the earlier README without tools.") - receive(session) + )) + display(session.run("/compact")) + display(session.run("What are the project's purpose and development commands? Use the earlier README without tools.")) if __name__ == "__main__": diff --git a/python/sdk/examples/goal.py b/python/sdk/examples/goal.py index 59676b3892..95baa7ec0b 100644 --- a/python/sdk/examples/goal.py +++ b/python/sdk/examples/goal.py @@ -52,12 +52,12 @@ def main() -> None: session = harness.start_session() with harness.client.subscribe_notifications(lambda notice: notice.payload.get("sessionId") == session.id) as notifications: deadline = time.monotonic() + args.timeout_seconds - session.query(f"/goal {args.objective}") - for message in session.receive_response(): - if isinstance(message, CommandExecution): - print(message.result.text, flush=True) - if message.result.kind == "error": - raise RuntimeError(message.result.text) + execution = session.run(f"/goal {args.objective}") + if not isinstance(execution, CommandExecution): + raise RuntimeError("The SDK profile did not execute the requested command") + print(execution.result.text, flush=True) + if execution.result.kind == "error": + raise RuntimeError(execution.result.text) while True: event = display(notifications.next(timeout_seconds=max(0, deadline - time.monotonic()))) if event is not None and event["type"] == "goal/change" and event["data"]["operation"] in { diff --git a/python/sdk/examples/minimal.py b/python/sdk/examples/minimal.py index 28d2fd8c54..fdbbd400a5 100644 --- a/python/sdk/examples/minimal.py +++ b/python/sdk/examples/minimal.py @@ -7,7 +7,7 @@ import argparse import os from pathlib import Path -from deepseek_harness import DeepSeekHarness +from deepseek_harness import CommandExecution, DeepSeekHarness def main() -> None: @@ -41,7 +41,12 @@ def main() -> None: profile=args.profile, ) as harness: result = harness.run(args.prompt, session_id=args.session_id) - print(result.final_response) + if isinstance(result, CommandExecution): + if result.result.kind == "error": + raise RuntimeError(result.result.text) + print(result.result.text or "") + else: + print(result.final_response) if __name__ == "__main__": diff --git a/python/sdk/examples/permission.py b/python/sdk/examples/permission.py index d97012f962..99b165044d 100644 --- a/python/sdk/examples/permission.py +++ b/python/sdk/examples/permission.py @@ -5,30 +5,27 @@ from __future__ import annotations import argparse import os -from contextlib import closing from pathlib import Path -from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session +from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult -def receive(session: Session) -> None: - """Print a command or activity result and stop on a reported failure.""" - with closing(session.receive_response()) as responses: - for result in responses: - if isinstance(result, CommandExecution): - print(f"Command {result.command_id}: {result.result.kind}") - if result.result.kind == "error": - raise RuntimeError(result.result.text) - if result.result.text is not None: - print(result.result.text) - elif isinstance(result, RunResult): - if result.finish_reason != "completed": - raise RuntimeError(f"Agent turn ended with {result.finish_reason}") - print(result.final_response) +def display(result: RunResult | CommandExecution) -> None: + """Print the input's result and stop on a reported failure.""" + if isinstance(result, CommandExecution): + print(f"Command {result.command_id}: {result.result.kind}") + if result.result.kind == "error": + raise RuntimeError(result.result.text) + if result.result.text is not None: + print(result.result.text) + else: + if result.finish_reason != "completed": + raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}") + print(result.final_response) def main() -> None: - """Send an explicit permission command, then query the same Session.""" + """Send an explicit permission command, then run another prompt in the same Session.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dsh-home", required=True, type=Path) parser.add_argument("--workspace", type=Path, default=Path.cwd()) @@ -45,13 +42,10 @@ def main() -> None: env={"DSH_PERMISSION_MODE": "workspace-write"}, ) as harness: session = harness.start_session() - session.query("Summarize the current file and approval policy in one sentence. Do not use tools.") - receive(session) - session.query(f"/permission {args.preset}") - receive(session) + display(session.run("Summarize the current file and approval policy in one sentence. Do not use tools.")) + display(session.run(f"/permission {args.preset}")) print(f"Launch setting: {harness.config.env['DSH_PERMISSION_MODE']}") - session.query("Summarize the current file and approval policy again. Do not use tools.") - receive(session) + display(session.run("Summarize the current file and approval policy again. Do not use tools.")) if __name__ == "__main__": diff --git a/python/sdk/examples/plan.py b/python/sdk/examples/plan.py index 6368c4d861..3d17df8617 100644 --- a/python/sdk/examples/plan.py +++ b/python/sdk/examples/plan.py @@ -72,12 +72,12 @@ def main() -> None: ) as harness: session = harness.start_session() with closing(harness.client.subscribe_session_notifications(session.id)) as notifications: - session.query(f"/plan {args.task}") - for message in session.receive_response(): - if isinstance(message, CommandExecution): - print(message.result.text) - if message.result.kind == "error": - raise RuntimeError(message.result.text) + execution = session.run(f"/plan {args.task}") + if not isinstance(execution, CommandExecution): + raise RuntimeError("The SDK profile did not execute the requested command") + print(execution.result.text, flush=True) + if execution.result.kind == "error": + raise RuntimeError(execution.result.text) harness.client.request( "session/wait", {"sessionId": session.id}, response_model=WaitResult, on_notification=lambda notice: display(notice, session.id), notification_subscription=notifications, diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index fa2eaeb368..0ffe1cca6a 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -1,14 +1,11 @@ from __future__ import annotations import uuid -import threading -from collections import deque -from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path -from typing import Callable, Literal +from typing import Callable, Literal, overload -from .client import HarnessClient, HarnessConfig, NotificationSubscription, UserQuestionHandler, _PendingRequest +from .client import HarnessClient, HarnessConfig, UserQuestionHandler from .errors import SdkProtocolError, TransportClosedError from .models import CommandExecution, JsonObject, Notification @@ -84,101 +81,6 @@ class _PromptActivity: ) -class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecution]): - def __init__( - self, client: HarnessClient, session_id: str, request: _PendingRequest, - subscription: NotificationSubscription, - ) -> None: - self._client = client - self._session_id = session_id - self._request = request - self._subscription = subscription - self._lock = threading.Lock() - self._closed = False - self._released = False - self._routed = False - self._activity: _PromptActivity | None = None - self._buffer: deque[Notification] = deque() - self._terminal: RunResult | CommandExecution | None = None - - def _release(self, error: BaseException | None = None) -> None: - with self._lock: - if self._released: - return - self._released = True - self._client._discard_request(self._request, error) - if error is None: - self._subscription.close() - else: - self._subscription._cancel(error) - - def close(self) -> None: - """Stop observing this query; the runtime operation is not cancelled.""" - with self._lock: - if self._closed: - return - self._closed = True - self._release(TransportClosedError("query response iterator closed")) - - def _ensure_open(self) -> None: - if self._closed: - raise StopIteration - - def __next__(self) -> Notification | RunResult | CommandExecution: - try: - self._ensure_open() - if not self._routed: - response = self._client._wait_request(self._request) - self._ensure_open() - self._routed = True - if not isinstance(response, dict): - raise SdkProtocolError("session/query response must be a JSON object") - if response.get("kind") == "command": - self._terminal = CommandExecution.model_validate(response.get("execution")) - self._release() - while True: - try: - notification = self._subscription._next_queued() - except TransportClosedError: - break - if notification is None: - break - self._buffer.append(notification) - if _is_command_done(notification, self._session_id, self._terminal.command_id): - break - elif response.get("kind") == "prompt" and isinstance(response.get("messageId"), str) and response["messageId"]: - self._activity = _PromptActivity(self._session_id, response["messageId"]) - else: - raise SdkProtocolError("session/query requires a prompt messageId or command execution result") - self._ensure_open() - if self._buffer: - return self._buffer.popleft() - if self._terminal is not None: - terminal = self._terminal - self._terminal = None - self._release() - self._closed = True - return terminal - assert self._activity is not None - while True: - notification = self._subscription.next() - self._ensure_open() - if not self._activity.record(notification): - continue - if (notification.method == "session.status" - and notification.payload.get("sessionId") == self._session_id - and notification.payload.get("status") == "idle"): - self._terminal = self._activity.result() - self._release() - return notification - except BaseException as error: - locally_closed = self._closed - self.close() - if locally_closed and isinstance(error, (TransportClosedError, StopIteration)): - raise StopIteration - raise - - class DeepSeekHarness: """Reusable synchronous SDK for running DeepSeek Harness agent turns. @@ -265,55 +167,35 @@ class DeepSeekHarness: self._client.resume_session(session_id) return Session(self, session_id) + @overload + def run( + self, input: list[JsonObject], *, session_id: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + ) -> RunResult: ... + + @overload + def run( + self, input: str | list[JsonObject], *, session_id: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + delivery: Literal["queue", "steer"] = "queue", + ) -> RunResult | CommandExecution: ... + def run( self, input: str | list[JsonObject], *, session_id: str | None = None, on_notification: Callable[[Notification], None] | None = None, - ) -> RunResult: - return self.start_session(session_id).run(input, on_notification=on_notification) + delivery: Literal["queue", "steer"] = "queue", + ) -> RunResult | CommandExecution: + """Run text or a native command on a fresh or named Session.""" + return self.start_session(session_id).run(input, on_notification=on_notification, delivery=delivery) class Session: def __init__(self, harness: DeepSeekHarness, session_id: str) -> None: self.harness = harness self.id = session_id - self._queries: deque[_QueryResponseIterator] = deque() - self._query_lock = threading.Lock() - - def query(self, text: str, *, delivery: Literal["queue", "steer"] = "queue") -> None: - """Send text for runtime prompt or command routing without waiting for the response. - - Pair each call with receive_response(). Delivery selects where ordinary - prompts enter the inbox; commands retain their native execution timing. - """ - if not isinstance(text, str): - raise TypeError("query text must be a string") - if delivery not in ("queue", "steer"): - raise ValueError("query delivery must be queue or steer") - self.harness.start() - client = self.harness.client - with self._query_lock: - subscription = client.subscribe_session_notifications(self.id) - try: - request = client._begin_request("session/query", {"sessionId": self.id, "text": text, "delivery": delivery}) - except BaseException: - subscription.close() - raise - self._queries.append(_QueryResponseIterator(client, self.id, request, subscription)) - - def receive_response(self) -> _QueryResponseIterator: - """Claim the next query in send order and iterate its notifications and terminal result. - - Prompts collect the inbox-receipt-to-idle interval used by run(); commands - finish on their RPC result. Close an unfinished iterator to release its - local observation resources without cancelling the runtime operation. - """ - with self._query_lock: - if not self._queries: - raise RuntimeError("receive_response requires a preceding query") - return self._queries.popleft() def get_working_directory(self) -> str: """Read the effective directory, recovering a missing directory to the Session origin.""" @@ -325,20 +207,68 @@ class Session: self.harness.start() return self.harness.client.set_working_directory(self.id, path) + @overload + def run( + self, input: list[JsonObject], *, + on_notification: Callable[[Notification], None] | None = None, + ) -> RunResult: ... + + @overload + def run( + self, input: str | list[JsonObject], *, + on_notification: Callable[[Notification], None] | None = None, + delivery: Literal["queue", "steer"] = "queue", + ) -> RunResult | CommandExecution: ... + def run( self, input: str | list[JsonObject], *, on_notification: Callable[[Notification], None] | None = None, - ) -> RunResult: - content_blocks = normalize_input(input) - with self.harness.client.subscribe_session_notifications(self.id) as subscription: - message_id = self.harness.client.session_prompt( - self.id, - content_blocks, - notification_subscription=subscription, - ) + delivery: Literal["queue", "steer"] = "queue", + ) -> RunResult | CommandExecution: + """Run text through native input routing, or queue literal content blocks. + Text returns the prompt's receipt-to-idle RunResult or a settled + CommandExecution; command completion does not wait for later model work. + Content blocks stay literal and accept only queue delivery. Notifications + retain wire order; command notifications are delivered after its response, + up to its own command/done event. Handler errors remain command results; + routing, transport and observer failures raise without rolling back work. + """ + if delivery not in ("queue", "steer"): + raise ValueError("run delivery must be queue or steer") + if not isinstance(input, str) and delivery != "queue": + raise ValueError("content-block input requires queue delivery") + self.harness.start() + with self.harness.client.subscribe_session_notifications(self.id) as subscription: + if isinstance(input, str): + response = self.harness.client._request_raw( + "session/query", {"sessionId": self.id, "text": input, "delivery": delivery}, + ) + if not isinstance(response, dict): + raise SdkProtocolError("session/query response must be a JSON object") + if response.get("kind") == "command": + execution = CommandExecution.model_validate(response.get("execution")) + while True: + try: + notification = subscription._next_queued() + except TransportClosedError: + break + if notification is None: + break + if on_notification is not None: + on_notification(notification) + if _is_command_done(notification, self.id, execution.command_id): + break + return execution + if response.get("kind") != "prompt" or not isinstance(response.get("messageId"), str) or not response["messageId"]: + raise SdkProtocolError("session/query requires a prompt messageId or command execution result") + message_id = response["messageId"] + else: + message_id = self.harness.client.session_prompt( + self.id, input, notification_subscription=subscription, + ) activity = _PromptActivity(self.id, message_id) while True: notification = subscription.next() diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 91a65b977c..946255aec3 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -39,14 +39,6 @@ class HarnessConfig: on_user_question: UserQuestionHandler | None = None -@dataclass(slots=True) -class _PendingRequest: - request_id: str - method: str - waiter: queue.Queue[JsonValue | BaseException] - deadline: float | None - - class HarnessClient: """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio.""" @@ -340,29 +332,15 @@ class HarnessClient: notification_filter: NotificationFilter | None = None, notification_subscription: "NotificationSubscription | None" = None, ) -> JsonValue: + request_id = str(uuid.uuid4()) + waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1) temp_subscription: NotificationSubscription | None = None subscription = notification_subscription + with self._lock: + self._responses[request_id] = waiter if on_notification is not None and subscription is None: temp_subscription = self.subscribe_notifications(notification_filter) subscription = temp_subscription - try: - pending = self._begin_request(method, params, timeout_seconds=timeout_seconds) - return self._wait_request(pending, on_notification=on_notification, notification_subscription=subscription) - finally: - if temp_subscription is not None: - temp_subscription.close() - - def _begin_request( - self, - method: str, - params: JsonObject | None = None, - *, - timeout_seconds: float | None = None, - ) -> _PendingRequest: - request_id = str(uuid.uuid4()) - waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1) - with self._lock: - self._responses[request_id] = waiter try: message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method} if params is not None: @@ -371,59 +349,48 @@ class HarnessClient: except BaseException: with self._lock: self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() raise timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds deadline = None if timeout is None else time.monotonic() + timeout - return _PendingRequest(request_id, method, waiter, deadline) - - def _discard_request(self, pending: _PendingRequest, error: BaseException | None = None) -> None: - with self._lock: - if self._responses.get(pending.request_id) is pending.waiter: - self._responses.pop(pending.request_id) - if error is not None: - pending.waiter.put_nowait(error) - - def _wait_request( - self, - pending: _PendingRequest, - *, - on_notification: Callable[[Notification], None] | None = None, - notification_subscription: "NotificationSubscription | None" = None, - ) -> JsonValue: - subscription = notification_subscription try: while True: if on_notification is not None and subscription is not None: subscription.drain(on_notification) - try: - item = pending.waiter.get_nowait() - break - except queue.Empty: - pass wait_timeout = None if on_notification is not None: wait_timeout = 0.05 - if pending.deadline is not None: - remaining = pending.deadline - time.monotonic() + if deadline is not None: + remaining = deadline - time.monotonic() if remaining <= 0: + with self._lock: + self._responses.pop(request_id, None) diagnostics = self._runtime_diagnostics() suffix = f"\n{diagnostics}" if diagnostics else "" raise TimeoutError( - f"{pending.method} timed out waiting for DeepSeek Harness runtime{suffix}" + f"{method} timed out waiting for DeepSeek Harness runtime{suffix}" ) wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining) try: - item = pending.waiter.get(timeout=wait_timeout) + item = waiter.get(timeout=wait_timeout) + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) break except queue.Empty: continue - if on_notification is not None and subscription is not None: - subscription.drain(on_notification) - if isinstance(item, BaseException): - raise item - return item + except BaseException: + with self._lock: + self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() + raise finally: - self._discard_request(pending) + if temp_subscription is not None: + temp_subscription.close() + if isinstance(item, BaseException): + raise item + return item def _write_message(self, message: JsonObject) -> None: proc = self._proc @@ -716,10 +683,6 @@ class NotificationSubscription: self._closed = True self._client._unsubscribe_notifications(self._subscription_id) - def _cancel(self, error: BaseException) -> None: - self.close() - self._notifications.put(error) - def next(self, timeout_seconds: float | None = None) -> Notification: """Read one notification; an optional timeout stops only this local wait.""" try: diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 1c2e6c821a..2a2ae8b7b6 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -178,7 +178,7 @@ for line in sys.stdin: "DEEPSEEK_BASE_URL": "http://127.0.0.1:4321", }, ) as harness: - result = harness.run("say hello", session_id="main") + result = harness.run([{"type": "text", "text": "say hello"}], session_id="main") assert result.final_response == "hello from runtime" assert result.finish_reason == "max-tokens" @@ -252,7 +252,7 @@ for line in sys.stdin: ) as harness: session = harness.start_session("main") result = session.run( - "spawn a helper", + [{"type": "text", "text": "spawn a helper"}], on_notification=lambda notification: seen.append(notification.method), ) @@ -292,7 +292,7 @@ for line in sys.stdin: SdkProtocolError, match=r"turn/end event requires a string data\.reason\.kind", ): - harness.run("reject malformed turn ending", session_id="main") + harness.run([{"type": "text", "text": "reject malformed turn ending"}], session_id="main") def test_relative_cwd_is_absolute_in_process_environment_and_wire( @@ -363,7 +363,7 @@ for line in sys.stdin: _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: - result = harness.run("spawn a helper", session_id="main") + result = harness.run([{"type": "text", "text": "spawn a helper"}], session_id="main") assert [notification.method for notification in result.notifications] == [ "session.event", @@ -413,7 +413,7 @@ for line in sys.stdin: cwd=str(tmp_path), ) as harness: result = harness.run( - "delegate recursively", + [{"type": "text", "text": "delegate recursively"}], session_id="main", on_notification=lambda notification: seen.append(notification.method), ) @@ -467,7 +467,7 @@ for line in sys.stdin: _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: - result = harness.run("stay in your lane", session_id="main") + result = harness.run([{"type": "text", "text": "stay in your lane"}], session_id="main") assert result.final_response == "right session" assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main"] * 4 @@ -499,7 +499,7 @@ for line in sys.stdin: ) with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: - result = harness.run("one turn", session_id="main") + result = harness.run([{"type": "text", "text": "one turn"}], session_id="main") assert harness.client._notifications.qsize() == 0 @@ -539,8 +539,8 @@ for line in sys.stdin: ) with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: - first = harness.run("first turn", session_id="main") - second = harness.run("second turn", session_id="main") + first = harness.run([{"type": "text", "text": "first turn"}], session_id="main") + second = harness.run([{"type": "text", "text": "second turn"}], session_id="main") assert first.final_response == "first" assert second.final_response == "second" diff --git a/python/sdk/tests/test_commands.py b/python/sdk/tests/test_commands.py index 7e221432d9..cda32429e4 100644 --- a/python/sdk/tests/test_commands.py +++ b/python/sdk/tests/test_commands.py @@ -129,9 +129,9 @@ def test_command_rejects_malformed_results(peer: Path, tmp_path: Path, response: sdk.client.session_command("main", "/compact") -def test_run_keeps_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None: +def test_content_blocks_keep_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None: with harness(peer, tmp_path, {"result": {}}) as sdk: - result = sdk.start_session("main").run("/compact") + result = sdk.start_session("main").run([{"type": "text", "text": "/compact"}]) assert result.final_response == "ordinary prompt" assert result.finish_reason == "completed" sent = frames(tmp_path) diff --git a/python/sdk/tests/test_commands_runtime.py b/python/sdk/tests/test_commands_runtime.py index a2cbc2ab25..11b3ee8e6b 100644 --- a/python/sdk/tests/test_commands_runtime.py +++ b/python/sdk/tests/test_commands_runtime.py @@ -13,7 +13,7 @@ import shutil import sys import threading from concurrent.futures import ThreadPoolExecutor -from contextlib import closing, contextmanager +from contextlib import contextmanager from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -275,22 +275,6 @@ def _request_texts(request: dict) -> list[str]: return [text for message in request["messages"] for text in _text_blocks(message["content"])] -def _receive_result( - session: Session, response_claimed: threading.Event | None = None, -) -> RunResult | CommandExecution: - result: RunResult | CommandExecution | None = None - with closing(session.receive_response()) as responses: - if response_claimed is not None: - response_claimed.set() - for item in responses: - assert result is None, "receive_response yielded another item after its terminal result" - if not isinstance(item, Notification): - assert isinstance(item, (RunResult, CommandExecution)), type(item) - result = item - assert result is not None, "receive_response ended without a terminal result" - return result - - def _wait_command_done(next_notification: Callable[[], Notification]) -> dict: while True: notification = next_notification() @@ -306,10 +290,8 @@ def test_compact_without_history_returns_without_a_model_turn(tmp_path: Path) -> session = runtime.harness.start_session(_SESSION_ID) observer = runtime.harness.client.subscribe_session_notifications(_SESSION_ID) try: - assert runtime.call(session.query, " \n/压缩\t ", delivery="queue") is None - # Wait independently for completion before starting the query's receiver. + execution = runtime.call(session.run, " \n/压缩\t ", delivery="queue") done = runtime.call(_wait_command_done, observer.next) - execution = runtime.call(_receive_result, session) except BaseException: # Wake a timed-out observer before unregistering its otherwise blocking queue. runtime.harness.close() @@ -332,20 +314,12 @@ def test_compact_rejects_busy_agent_before_the_model_is_released(tmp_path: Path) response = _Completion("DSH_COMMANDS_BUSY_REPLY", blocked=True) with _runtime(tmp_path, [response]) as runtime: session = runtime.harness.start_session(_SESSION_ID) - sending = runtime.workers.submit( - session.query, "Hold this request while compaction is attempted.", delivery="queue", + running = runtime.workers.submit( + session.run, "Hold this request while compaction is attempted.", delivery="queue", ) assert response.entered.wait(_WAIT_SECONDS), runtime.model.failures - assert sending.result(timeout=_WAIT_SECONDS) is None - assert not response.release.is_set(), "query waited for the model before returning" - - # Claim the prompt's FIFO slot before issuing the command for another receiver. - receiving = threading.Event() - running = runtime.workers.submit(_receive_result, session, receiving) - assert receiving.wait(_WAIT_SECONDS), runtime.model.failures - assert not running.done() - assert runtime.call(session.query, "/compact", delivery="queue") is None - execution = runtime.call(_receive_result, session) + assert not running.done() and not response.release.is_set() + execution = runtime.call(session.run, "/compact", delivery="queue") assert isinstance(execution, CommandExecution) assert execution.result.kind == "error" assert not response.release.is_set() @@ -371,18 +345,13 @@ def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None session = runtime.harness.start_session(_SESSION_ID) # The replaced span is deliberately larger than the framed summary. prompt = _HISTORY_MARKER + "\n" + "Keep the original request only in the stored history. " * 256 - assert runtime.call(session.query, prompt, delivery="queue") is None - first_result = runtime.call(_receive_result, session) + first_result = runtime.call(session.run, prompt, delivery="queue") assert isinstance(first_result, RunResult) assert first_result.final_response == _FIRST_REPLY - sending = runtime.workers.submit(session.query, "/compact", delivery="queue") + compacting = runtime.workers.submit(session.run, "/compact", delivery="queue") assert summary_response.entered.wait(_WAIT_SECONDS), runtime.model.failures - assert sending.result(timeout=_WAIT_SECONDS) is None - assert not summary_response.release.is_set(), "query waited for the summary before returning" - receiving = threading.Event() - compacting = runtime.workers.submit(_receive_result, session, receiving) - assert receiving.wait(_WAIT_SECONDS), runtime.model.failures + assert not summary_response.release.is_set() assert not compacting.done(), "command returned before its summary request completed" summary_response.release.set() execution = compacting.result(timeout=_WAIT_SECONDS) @@ -415,8 +384,7 @@ def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None assert "" in checkpoint_text and "" in checkpoint_text assert len(_of_type(checkpointed, "turn/start")) == len(_of_type(checkpointed, "turn/end")) == 1 - assert runtime.call(session.query, _CONTINUE_PROMPT, delivery="queue") is None - continued = runtime.call(_receive_result, session) + continued = runtime.call(session.run, _CONTINUE_PROMPT, delivery="queue") assert isinstance(continued, RunResult) assert continued.final_response == _CONTINUE_REPLY first, compaction, following = runtime.model.captured() @@ -452,8 +420,7 @@ def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_pa with _runtime(tmp_path, [_Completion(text) for text in replies]) as runtime: session = runtime.harness.start_session(_SESSION_ID) for prompt, reply in zip(prompts, replies): - assert runtime.call(session.query, prompt, delivery="queue") is None - result = runtime.call(_receive_result, session) + result = runtime.call(session.run, prompt, delivery="queue") assert isinstance(result, RunResult) assert result.final_response == reply requests = runtime.model.captured() @@ -473,13 +440,11 @@ def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_p reply = "DSH_AFTER_PLAN_TEXT_REPLY" with _runtime(tmp_path, [_Completion(reply_before), _Completion(reply)]) as runtime: session = runtime.harness.start_session(_SESSION_ID) - assert runtime.call(session.query, input_text, delivery="queue") is None - first = runtime.call(_receive_result, session) + first = runtime.call(session.run, input_text, delivery="queue") assert isinstance(first, RunResult) assert first.final_response == reply_before - assert runtime.call(session.query, following, delivery="queue") is None - result = runtime.call(_receive_result, session) + result = runtime.call(session.run, following, delivery="queue") assert isinstance(result, RunResult) assert result.final_response == reply initial, continued = runtime.model.captured() @@ -510,8 +475,7 @@ def _permission_facts(events: list[dict]) -> list[dict]: def _permission_command(runtime: _Runtime, session: Session, preset: str) -> CommandExecution: - assert runtime.call(session.query, f"/permission {preset}") is None - result = runtime.call(_receive_result, session) + result = runtime.call(session.run, f"/permission {preset}") assert isinstance(result, CommandExecution) return result @@ -524,8 +488,7 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti initial_config = dict(runtime.harness.config.env) for session, text in [(main, "Initial main policy; do not use tools."), (other, "Initial other policy; do not use tools.")]: - runtime.call(session.query, text) - assert isinstance(runtime.call(_receive_result, session), RunResult) + assert isinstance(runtime.call(session.run, text), RunResult) readonly = _permission_command(runtime, main, "read-only") invalid = _permission_command(runtime, main, "no-such-preset") @@ -535,25 +498,20 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti assert "unknown preset" in invalid.result.text assert len(runtime.model.captured()) == 2, "idle permission commands woke the model" - runtime.call(main.query, "Read-only policy after a plain query; do not use tools.") - assert isinstance(runtime.call(_receive_result, main), RunResult) + assert isinstance(runtime.call(main.run, "Read-only policy after a plain query; do not use tools."), RunResult) full = _permission_command(runtime, main, "danger-full-access") assert full.result.kind == "success" assert len(runtime.model.captured()) == 3 - runtime.call(main.query, "Full policy after a plain query; do not use tools.") - assert isinstance(runtime.call(_receive_result, main), RunResult) + assert isinstance(runtime.call(main.run, "Full policy after a plain query; do not use tools."), RunResult) restored = _permission_command(runtime, main, "workspace-write") assert restored.result.kind == "success" - runtime.call(main.query, "Switched-back policy; do not use tools.") - assert isinstance(runtime.call(_receive_result, main), RunResult) + assert isinstance(runtime.call(main.run, "Switched-back policy; do not use tools."), RunResult) for session in [other, runtime.harness.start_session("permission-new")]: - runtime.call(session.query, "Report your own policy; do not use tools.") - assert isinstance(runtime.call(_receive_result, session), RunResult) - ordinary = runtime.call(main.run, "/permission danger-full-access") + assert isinstance(runtime.call(session.run, "Report your own policy; do not use tools."), RunResult) + ordinary = runtime.call(main.run, [{"type": "text", "text": "/permission danger-full-access"}]) assert isinstance(ordinary, RunResult) - runtime.call(main.query, "/permission") - assert isinstance(runtime.call(_receive_result, main), RunResult) + assert isinstance(runtime.call(main.run, "/permission"), RunResult) assert runtime.harness.config.env == initial_config requests = runtime.model.captured() @@ -596,11 +554,8 @@ def test_running_permission_change_keeps_the_native_extra_step_and_notifications second = _Completion("PERMISSION_SWITCH_SEEN") with _runtime(tmp_path, [first, second], initial_permission="workspace-write") as runtime: session = runtime.harness.start_session(_SESSION_ID) - runtime.call(session.query, "Wait for the policy change, then finish without tools.") + observing = runtime.workers.submit(session.run, "Wait for the policy change, then finish without tools.") assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures - claimed = threading.Event() - observing = runtime.workers.submit(_receive_result, session, claimed) - assert claimed.wait(_WAIT_SECONDS) execution = _permission_command(runtime, session, "danger-full-access") assert execution.result.kind == "success" assert not first.release.is_set() @@ -639,8 +594,7 @@ def test_resumed_permission_survives_different_launch_defaults(tmp_path: Path) - saved = _permission_facts(_read_events(runtime.logs)) with _runtime(tmp_path, [_Completion("RESUMED_PERMISSION")], initial_permission="danger-full-access") as resumed: session = resumed.call(resumed.harness.resume_session, _SESSION_ID) - resumed.call(session.query, "Report the restored policy without using tools.") - assert isinstance(resumed.call(_receive_result, session), RunResult) + assert isinstance(resumed.call(session.run, "Report the restored policy without using tools."), RunResult) [request] = resumed.model.captured() _assert_request_permission(request, "read-only", "ask") assert resumed.harness.config.env["DSH_PERMISSION_MODE"] == "danger-full-access" diff --git a/python/sdk/tests/test_cot_translation_projection.py b/python/sdk/tests/test_cot_translation_projection.py index 6ffb43c623..256d1a4f02 100644 --- a/python/sdk/tests/test_cot_translation_projection.py +++ b/python/sdk/tests/test_cot_translation_projection.py @@ -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")] diff --git a/python/sdk/tests/test_goal_runtime.py b/python/sdk/tests/test_goal_runtime.py index fe9f9602e5..5044792d5f 100644 --- a/python/sdk/tests/test_goal_runtime.py +++ b/python/sdk/tests/test_goal_runtime.py @@ -4,21 +4,23 @@ from __future__ import annotations import json import time +import threading + +import pytest from pathlib import Path from deepseek_harness import CommandExecution, RunResult, Session from deepseek_harness.client import NotificationSubscription from test_commands_runtime import ( _Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission, - _command_pair, _of_type, _permission_facts, _read_events, _receive_result, + _command_pair, _of_type, _permission_facts, _read_events, _request_texts, _runtime, _text_blocks, ) -from test_plan_runtime import _wait_activity +from test_plan_runtime import _WaitResult, _wait_activity def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution: - runtime.call(session.query, text) - result = runtime.call(_receive_result, session) + result = runtime.call(session.run, text) assert isinstance(result, CommandExecution) assert result.result.kind == kind, result.result.text return result @@ -63,7 +65,7 @@ def test_goal_reads_and_invalid_controls_do_not_start_model_work(tmp_path: Path) _command_pair(events, execution, "goal") -def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path) -> None: +def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: final = _Completion("GOAL_DONE", blocked=True) with _runtime(tmp_path, [_Completion("PROGRESS"), *_completion_steps(final)], initial_permission="workspace-write", max_goal_rounds=3) as runtime: @@ -73,13 +75,35 @@ def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_pa completed = _until(events, "goal/change", "complete") assert completed["goal"]["phase"] == "complete" and completed["roundsStarted"] == 2 assert final.entered.wait(_WAIT_SECONDS) - waiting = runtime.harness.client._begin_request("session/wait", {"sessionId": session.id}) - # A later RPC confirms admission on the same pipe; keep the wait's - # response queued so worker scheduling cannot hide an early return. - runtime.call(runtime.harness.client.get_working_directory, session.id) - assert waiting.waiter.empty() and not final.release.is_set() + client = runtime.harness.client + sent = threading.Event() + replied = threading.Event() + wait_ids = set() + write = client._write_message + handle = client._handle_message + + def record_send(message: dict) -> None: + is_wait = message.get("method") == "session/wait" + if is_wait: + wait_ids.add(message["id"]) + write(message) + if is_wait: + sent.set() + + def record_response(message: object) -> None: + if isinstance(message, dict) and "method" not in message and message.get("id") in wait_ids: + replied.set() + handle(message) + + monkeypatch.setattr(client, "_write_message", record_send) + monkeypatch.setattr(client, "_handle_message", record_response) + waiting = runtime.workers.submit(client.request, "session/wait", {"sessionId": session.id}, response_model=_WaitResult) + assert sent.wait(_WAIT_SECONDS) + # Observe the reader directly so caller scheduling cannot hide an early response. + runtime.call(client.get_working_directory, session.id) + assert not replied.is_set() and not final.release.is_set() final.release.set() - assert runtime.call(runtime.harness.client._wait_request, waiting) == {} + assert waiting.result(timeout=_WAIT_SECONDS).model_dump() == {} assert _until(events, "turn/end")["turn"] == 2 requests = runtime.model.captured() assert len(requests) == 4 @@ -121,7 +145,7 @@ def test_pause_aborts_then_edit_and_resume_use_revised_goal_without_changing_oth assert 'Objective: "Revised objective"' in "\n".join(_request_texts(runtime.model.captured()[1])) _command(runtime, session, "/goal clear") _command(runtime, session, "/goal clear") - result = runtime.call(session.run, "/goal Plain text objective") + result = runtime.call(session.run, [{"type": "text", "text": "/goal Plain text objective"}]) assert isinstance(result, RunResult) and result.final_response == "RAW_GOAL_TEXT" assert "/goal Plain text objective" in _request_texts(runtime.model.captured()[-1]) changes = _of_type(_read_events(runtime.logs), "goal/change") diff --git a/python/sdk/tests/test_plan_runtime.py b/python/sdk/tests/test_plan_runtime.py index 1dfb118acc..1370d7c197 100644 --- a/python/sdk/tests/test_plan_runtime.py +++ b/python/sdk/tests/test_plan_runtime.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from deepseek_harness import CommandExecution, RunResult, Session, UserQuestionRequest from test_commands_runtime import ( _Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission, - _command_pair, _of_type, _permission_facts, _read_events, _receive_result, + _command_pair, _of_type, _permission_facts, _read_events, _request_texts, _runtime, _text_blocks, ) @@ -25,8 +25,7 @@ class _WaitResult(BaseModel): def _command(runtime: _Runtime, session: Session, text: str) -> CommandExecution: - assert runtime.call(session.query, text) is None - result = runtime.call(_receive_result, session) + result = runtime.call(session.run, text) assert isinstance(result, CommandExecution) assert result.result.kind == "success", result.result.text return result @@ -94,8 +93,7 @@ def test_plan_body_uses_native_input_then_off_removes_guidance_but_keeps_exit_to assert {"exit_plan_mode", "write", "edit"} <= _tool_names(first) _assert_request_permission(first, "workspace-write", "ask") _command(runtime, session, "/plan off") - runtime.call(session.query, "Continue after the user changed the mode.") - result = runtime.call(_receive_result, session) + result = runtime.call(session.run, "Continue after the user changed the mode.") assert isinstance(result, RunResult) and result.final_response == "AFTER_OFF" second = runtime.model.captured()[1] assert _GUIDANCE not in _effective_system(second) @@ -112,11 +110,8 @@ def test_plan_body_steers_the_next_step_of_a_running_turn(tmp_path: Path) -> Non first = _Completion("FIRST_REPLY", blocked=True) with _runtime(tmp_path, [first, _Completion("PLANNING_REPLY")], initial_permission="workspace-write") as runtime: session = runtime.harness.start_session(_SESSION_ID) - runtime.call(session.query, "Start this task without tools.") + observing = runtime.workers.submit(session.run, "Start this task without tools.") assert first.entered.wait(_WAIT_SECONDS) - claimed = threading.Event() - observing = runtime.workers.submit(_receive_result, session, claimed) - assert claimed.wait(_WAIT_SECONDS) execution = _command(runtime, session, "/plan Design a smaller change.") assert not first.release.is_set() and not observing.done() first.release.set() @@ -138,11 +133,8 @@ def test_running_bare_selection_does_not_force_a_step_and_can_be_cancelled(tmp_p first = _Completion("FIRST_REPLY", blocked=True) with _runtime(tmp_path, [first, _Completion("NEXT_REPLY")], initial_permission="workspace-write") as runtime: session = runtime.harness.start_session(_SESSION_ID) - runtime.call(session.query, "Start without tools.") + observing = runtime.workers.submit(session.run, "Start without tools.") assert first.entered.wait(_WAIT_SECONDS) - claimed = threading.Event() - observing = runtime.workers.submit(_receive_result, session, claimed) - assert claimed.wait(_WAIT_SECONDS) _command(runtime, session, "/plan") if cancel: cancelled = _command(runtime, session, "/plan off") @@ -151,8 +143,7 @@ def test_running_bare_selection_does_not_force_a_step_and_can_be_cancelled(tmp_p first.release.set() assert isinstance(observing.result(timeout=_WAIT_SECONDS), RunResult) assert len(runtime.model.captured()) == 1 - runtime.call(session.query, "Inspect the next task without tools.") - assert isinstance(runtime.call(_receive_result, session), RunResult) + assert isinstance(runtime.call(session.run, "Inspect the next task without tools."), RunResult) assert (_GUIDANCE in _effective_system(runtime.model.captured()[1])) is (not cancel) events = _read_events(runtime.logs) assert len(_of_type(events, "turn/start")) == 2 @@ -225,8 +216,7 @@ def test_plan_state_survives_resume_without_changing_permissions(tmp_path: Path) _command(runtime, session, "/plan") with _runtime(tmp_path, [_Completion("RESTORED_PLAN")], initial_permission="workspace-write") as resumed: session = resumed.call(resumed.harness.resume_session, _SESSION_ID) - resumed.call(session.query, "Continue planning without tools.") - assert isinstance(resumed.call(_receive_result, session), RunResult) + assert isinstance(resumed.call(session.run, "Continue planning without tools."), RunResult) [request] = resumed.model.captured() assert _GUIDANCE in _effective_system(request) _assert_request_permission(request, "workspace-write", "ask") @@ -240,11 +230,9 @@ def test_plan_state_is_local_and_run_keeps_ordinary_text_semantics(tmp_path: Pat session = runtime.harness.start_session(_SESSION_ID) _command(runtime, session, "/plan") other = runtime.harness.start_session("plan-other") - runtime.call(other.query, "Inspect your own task without tools.") - assert isinstance(runtime.call(_receive_result, other), RunResult) - runtime.call(session.query, "Inspect the plan without tools.") - assert isinstance(runtime.call(_receive_result, session), RunResult) - result = runtime.call(session.run, "/plan off") + assert isinstance(runtime.call(other.run, "Inspect your own task without tools."), RunResult) + assert isinstance(runtime.call(session.run, "Inspect the plan without tools."), RunResult) + result = runtime.call(session.run, [{"type": "text", "text": "/plan off"}]) assert isinstance(result, RunResult) first, second, third = runtime.model.captured() assert _GUIDANCE not in _effective_system(first) diff --git a/python/sdk/tests/test_queries.py b/python/sdk/tests/test_queries.py deleted file mode 100644 index 02256aaa09..0000000000 --- a/python/sdk/tests/test_queries.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Split query submission and per-request response observation.""" -from __future__ import annotations - -import sys -import threading -from pathlib import Path - -import pytest -from pydantic import BaseModel, ValidationError - -from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, SdkProtocolError -from deepseek_harness.errors import JsonRpcError, TransportClosedError - - -class Ack(BaseModel): - pass - - -@pytest.fixture -def peer(tmp_path: Path) -> Path: - script = tmp_path / "queries.py" - script.write_text('''import json -import sys - -serial = 0 -pending_commands = [] -pending_prompts = [] - -def send(frame): - print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True) - -def event(session, type, data): - send({"method": "session.event", "params": {"sessionId": session, "event": {"type": type, "data": data}}}) - -def command(frame, number): - session = frame["params"]["sessionId"] - identity = "command-" + str(number) - event(session, "command/run", {"commandId": identity}) - event(session, "command/done", {"commandId": identity, "kind": "success"}) - send({"id": frame["id"], "result": {"kind": "command", "execution": { - "commandId": identity, "result": {"kind": "success", "text": "compacted"}}}}) - -def receipt(frame, number): - session = frame["params"]["sessionId"] - send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}}) - event(session, "agent/inbox/spliced", {"inserted": [{"id": "input-" + str(number)}]}) - -def finish(frame): - session = frame["params"]["sessionId"] - event(session, "assistant/message", {"message": {"content": [ - {"type": "text", "text": "answer for " + frame["params"]["text"]}]}}) - event(session, "turn/end", {"turn": 1, "reason": {"kind": "completed"}}) - send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}}) - -for line in sys.stdin: - frame = json.loads(line) - method = frame["method"] - if method == "initialize": - send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}}) - elif method == "session/query": - serial += 1 - params = frame["params"] - assert set(params) == {"sessionId", "text", "delivery"} - assert params["delivery"] in ("queue", "steer") - text = params["text"] - if text == "hold-command": - pending_commands.append((frame, serial)) - elif text == "/compact": - command(frame, serial) - elif text == "silent-command": - send({"id": frame["id"], "result": {"kind": "command", "execution": { - "commandId": "silent", "result": {"kind": "success"}}}}) - elif text == "rpc-error": - send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}}) - elif text == "bad-route": - send({"id": frame["id"], "result": {"kind": "unknown"}}) - elif text == "bad-execution": - send({"id": frame["id"], "result": {"kind": "command", "execution": {"commandId": "c"}}}) - else: - result = {"kind": "prompt", "messageId": "input-" + str(serial)} - if text == "fast-prompt": - receipt(frame, serial) - finish(frame) - send({"id": frame["id"], "result": result}) - else: - send({"id": frame["id"], "result": result}) - receipt(frame, serial) - if text == "hold-prompt": - pending_prompts.append(frame) - else: - finish(frame) - elif method == "release": - for original, number in pending_commands: - command(original, number) - pending_commands.clear() - for original in pending_prompts: - finish(original) - pending_prompts.clear() - send({"id": frame["id"], "result": {}}) - elif method == "barrier": - send({"id": frame["id"], "result": {}}) - elif method == "disconnect": - send({"id": frame["id"], "result": {}}) - break - elif method == "shutdown": - send({"id": frame["id"], "result": {}}) - break - else: - raise AssertionError(method) -''', encoding="utf-8") - return script - - -@pytest.fixture -def sdk(peer: Path, tmp_path: Path): - with DeepSeekHarness( - cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5, - _launch_args=(sys.executable, str(peer)), - ) as harness: - yield harness - - -def release(sdk: DeepSeekHarness) -> None: - sdk.client.request("release", {}, response_model=Ack, timeout_seconds=5) - - -def assert_released(sdk: DeepSeekHarness) -> None: - assert sdk.client._responses == {} - assert sdk.client._notification_subscribers == {} - - -def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - assert session.query("hold-command") is None - assert len(sdk.client._responses) == 1 - release(sdk) - responses = list(session.receive_response()) - assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"] - assert isinstance(responses[-1], CommandExecution) - assert responses[-1].command_id == "command-1" - assert_released(sdk) - - -@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", "/permission"]) -@pytest.mark.parametrize("delivery", ["queue", "steer"]) -def test_prompt_yields_receipt_through_idle_then_run_result(sdk: DeepSeekHarness, text: str, delivery: str) -> None: - session = sdk.start_session("main") - session.query(text, delivery=delivery) - responses = list(session.receive_response()) - assert all(isinstance(item, Notification) for item in responses[:-1]) - assert responses[0].payload["event"]["type"] == "agent/inbox/spliced" - result = responses[-1] - assert isinstance(result, RunResult) - assert result.final_response == "answer for " + text - assert result.finish_reason == "completed" - assert result.notifications == responses[:-1] - assert len(result.events) == 3 - assert_released(sdk) - - -def test_late_receive_uses_an_arrived_response_before_expired_deadline(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - sdk.client.config.request_timeout_seconds = 0 - session.query("/compact") - sdk.client.request("barrier", {}, response_model=Ack, timeout_seconds=5) - assert isinstance(list(session.receive_response())[-1], CommandExecution) - assert_released(sdk) - - -def test_pending_response_timeout_releases_only_its_observer(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - sdk.client.config.request_timeout_seconds = 0 - session.query("hold-command") - with pytest.raises(TimeoutError, match="session/query timed out"): - list(session.receive_response()) - assert_released(sdk) - release(sdk) - - -@pytest.mark.parametrize("text,error", [ - ("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError), -]) -def test_query_errors_arrive_during_receive_and_leave_later_queries_usable(sdk: DeepSeekHarness, text: str, error: type) -> None: - session = sdk.start_session("main") - session.query(text) - with pytest.raises(error): - list(session.receive_response()) - assert_released(sdk) - session.query("/compact") - assert isinstance(list(session.receive_response())[-1], CommandExecution) - - -def test_receivers_claim_fifo_even_when_responses_arrive_out_of_order(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("hold-command") - session.query("/compact") - release(sdk) - first = session.receive_response() - second = session.receive_response() - assert list(second)[-1].command_id == "command-2" - assert list(first)[-1].command_id == "command-1" - assert_released(sdk) - with pytest.raises(RuntimeError, match="preceding query"): - session.receive_response() - - -def test_command_receiver_stops_at_its_done_before_a_later_prompt(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("/compact") - session.query("ordinary") - sdk.client.request("barrier", {}, response_model=Ack) - command = list(session.receive_response()) - assert [item.payload["event"]["type"] for item in command[:-1]] == ["command/run", "command/done"] - assert command[-1].command_id == "command-1" - prompt = list(session.receive_response()) - assert isinstance(prompt[-1], RunResult) - assert prompt[-1].final_response == "answer for ordinary" - assert_released(sdk) - - -def test_command_result_without_notifications_does_not_wait_for_done(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("silent-command") - responses = list(session.receive_response()) - assert len(responses) == 1 - assert responses[0].command_id == "silent" - assert_released(sdk) - - -@pytest.mark.parametrize("text", ["/compact", "silent-command"]) -def test_late_command_receive_preserves_completed_result_after_eof(sdk: DeepSeekHarness, text: str) -> None: - session = sdk.start_session("main") - session.query(text) - sdk.client.request("disconnect", {}, response_model=Ack) - sdk.client._reader_thread.join(5) - assert not sdk.client._reader_thread.is_alive() - responses = list(session.receive_response()) - assert isinstance(responses[-1], CommandExecution) - assert responses[-1].result.kind == "success" - if text == "/compact": - assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"] - else: - assert len(responses) == 1 - assert_released(sdk) - - -def test_command_receive_does_not_hide_other_subscription_errors(sdk: DeepSeekHarness, monkeypatch) -> None: - session = sdk.start_session("main") - session.query("/compact") - sdk.client.request("barrier", {}, response_model=Ack) - receiver = session.receive_response() - - def fail(): - raise SdkProtocolError("invalid notification") - - monkeypatch.setattr(receiver._subscription, "_next_queued", fail) - with pytest.raises(SdkProtocolError, match="invalid notification"): - list(receiver) - assert_released(sdk) - - -def test_query_and_another_receiver_can_run_while_a_prompt_receiver_waits(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("hold-prompt") - receiver = session.receive_response() - entered = threading.Event() - responses = [] - errors = [] - - def receive() -> None: - try: - for item in receiver: - responses.append(item) - entered.set() - except BaseException as error: - errors.append(error) - - thread = threading.Thread(target=receive) - thread.start() - try: - assert entered.wait(5) - session.query("/compact") - assert list(session.receive_response())[-1].command_id == "command-2" - assert thread.is_alive() - release(sdk) - thread.join(5) - assert not thread.is_alive() - assert not errors - assert isinstance(responses[-1], RunResult) - assert responses[-1].final_response == "answer for hold-prompt" - assert_released(sdk) - finally: - receiver.close() - thread.join(5) - - -def test_iterator_close_before_first_next_releases_request_and_subscription(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("hold-command") - receiver = session.receive_response() - receiver.close() - receiver.close() - assert list(receiver) == [] - assert_released(sdk) - release(sdk) - session.query("/compact") - assert list(session.receive_response())[-1].command_id == "command-2" - - -def test_iterator_close_after_a_prompt_notification_releases_its_subscription(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("hold-prompt") - receiver = session.receive_response() - assert isinstance(next(receiver), Notification) - receiver.close() - assert list(receiver) == [] - assert_released(sdk) - release(sdk) - - -def test_iterator_close_wakes_its_blocked_request_receiver(sdk: DeepSeekHarness, monkeypatch) -> None: - session = sdk.start_session("main") - session.query("hold-command") - receiver = session.receive_response() - entered = threading.Event() - errors = [] - original = sdk.client._wait_request - - def wait(request, **kwargs): - entered.set() - return original(request, **kwargs) - - monkeypatch.setattr(sdk.client, "_wait_request", wait) - - def receive() -> None: - try: - assert list(receiver) == [] - except BaseException as error: - errors.append(error) - - thread = threading.Thread(target=receive) - thread.start() - try: - assert entered.wait(5) - receiver.close() - thread.join(5) - assert not thread.is_alive() - assert not errors - assert_released(sdk) - finally: - receiver.close() - thread.join(5) - - -def test_terminal_result_releases_resources_before_caller_sends_another_query(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("/compact") - receiver = session.receive_response() - for item in receiver: - if isinstance(item, CommandExecution): - assert_released(sdk) - session.query("/compact") - receiver.close() - assert list(session.receive_response())[-1].command_id == "command-2" - assert_released(sdk) - - -def test_runtime_close_settles_unreceived_queries(sdk: DeepSeekHarness) -> None: - session = sdk.start_session("main") - session.query("hold-command") - sdk.close() - with pytest.raises(TransportClosedError): - list(session.receive_response()) - assert_released(sdk) - - -def test_send_failure_releases_subscription_without_queueing_a_receiver(sdk: DeepSeekHarness, monkeypatch) -> None: - session = sdk.start_session("main") - original = sdk.client._write_message - - def write(message): - if message.get("method") == "session/query": - raise TransportClosedError("write failed") - original(message) - - monkeypatch.setattr(sdk.client, "_write_message", write) - with pytest.raises(TransportClosedError, match="write failed"): - session.query("ordinary") - assert_released(sdk) - with pytest.raises(RuntimeError, match="preceding query"): - session.receive_response() diff --git a/python/sdk/tests/test_run.py b/python/sdk/tests/test_run.py new file mode 100644 index 0000000000..bcab8e24ca --- /dev/null +++ b/python/sdk/tests/test_run.py @@ -0,0 +1,221 @@ +"""Unified blocking input, native command results and independent run observation.""" +from __future__ import annotations + +import sys +import threading +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor + +import pytest +from pydantic import BaseModel, ValidationError + +from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, SdkProtocolError +from deepseek_harness.errors import JsonRpcError + + +class Ack(BaseModel): + pass + + +@pytest.fixture +def peer(tmp_path: Path) -> Path: + script = tmp_path / "queries.py" + script.write_text('''import json +import sys + +serial = 0 +pending_commands = [] +pending_prompts = [] + +def send(frame): + print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True) + +def event(session, type, data): + send({"method": "session.event", "params": {"sessionId": session, "event": {"type": type, "data": data}}}) + +def command(frame, number): + session = frame["params"]["sessionId"] + identity = "command-" + str(number) + event(session, "command/run", {"commandId": identity}) + event(session, "command/done", {"commandId": identity, "kind": "success"}) + send({"id": frame["id"], "result": {"kind": "command", "execution": { + "commandId": identity, "result": {"kind": "success", "text": "compacted"}}}}) + +def receipt(frame, number): + session = frame["params"]["sessionId"] + send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}}) + event(session, "agent/inbox/spliced", {"inserted": [{"id": "input-" + str(number)}]}) + +def finish(frame): + session = frame["params"]["sessionId"] + event(session, "assistant/message", {"message": {"content": [ + {"type": "text", "text": "answer for " + frame["params"]["text"]}]}}) + event(session, "turn/end", {"turn": 1, "reason": {"kind": "completed"}}) + send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}}) + +for line in sys.stdin: + frame = json.loads(line) + method = frame["method"] + if method == "initialize": + send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}}) + elif method == "session/query": + serial += 1 + params = frame["params"] + assert set(params) == {"sessionId", "text", "delivery"} + assert params["delivery"] in ("queue", "steer") + text = params["text"] + if text == "hold-command": + pending_commands.append((frame, serial)) + elif text == "/compact": + command(frame, serial) + elif text == "silent-command": + send({"id": frame["id"], "result": {"kind": "command", "execution": { + "commandId": "silent", "result": {"kind": "success"}}}}) + elif text == "rpc-error": + send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}}) + elif text == "bad-route": + send({"id": frame["id"], "result": {"kind": "unknown"}}) + elif text == "bad-execution": + send({"id": frame["id"], "result": {"kind": "command", "execution": {"commandId": "c"}}}) + else: + result = {"kind": "prompt", "messageId": "input-" + str(serial)} + if text == "fast-prompt": + receipt(frame, serial) + finish(frame) + send({"id": frame["id"], "result": result}) + else: + send({"id": frame["id"], "result": result}) + receipt(frame, serial) + if text == "hold-prompt": + pending_prompts.append(frame) + else: + finish(frame) + elif method == "release": + for original, number in pending_commands: + command(original, number) + pending_commands.clear() + for original in pending_prompts: + finish(original) + pending_prompts.clear() + send({"id": frame["id"], "result": {}}) + elif method == "barrier": + send({"id": frame["id"], "result": {}}) + elif method == "disconnect": + send({"id": frame["id"], "result": {}}) + break + elif method == "shutdown": + send({"id": frame["id"], "result": {}}) + break + else: + raise AssertionError(method) +''', encoding="utf-8") + return script + + +@pytest.fixture +def sdk(peer: Path, tmp_path: Path): + with DeepSeekHarness( + cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5, + _launch_args=(sys.executable, str(peer)), + ) as harness: + yield harness + + +def release(sdk: DeepSeekHarness) -> None: + sdk.client.request("release", {}, response_model=Ack, timeout_seconds=5) + + +def assert_released(sdk: DeepSeekHarness) -> None: + assert sdk.client._responses == {} + assert sdk.client._notification_subscribers == {} + + +@pytest.mark.parametrize("entry", ["session", "harness"]) +def test_run_returns_command_without_waiting_for_idle(sdk: DeepSeekHarness, entry: str) -> None: + notifications = [] + result = (sdk.run("/compact", session_id="main", on_notification=notifications.append) + if entry == "harness" else sdk.start_session("main").run("/compact", on_notification=notifications.append)) + assert isinstance(result, CommandExecution) + assert result.result.kind == "success" + assert [notice.payload["event"]["type"] for notice in notifications] == ["command/run", "command/done"] + assert_released(sdk) + + +@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", " /compact extra "]) +def test_run_preserves_ordinary_text_and_result(sdk: DeepSeekHarness, text: str) -> None: + notifications = [] + result = sdk.start_session("main").run(text, on_notification=notifications.append) + assert isinstance(result, RunResult) + assert result.final_response == "answer for " + text + assert result.finish_reason == "completed" + assert result.notifications == notifications + assert notifications[0].payload["event"]["type"] == "agent/inbox/spliced" + assert_released(sdk) + + +def test_run_accepts_steer_for_text(sdk: DeepSeekHarness) -> None: + result = sdk.start_session("main").run("next step", delivery="steer") + assert isinstance(result, RunResult) + assert result.final_response == "answer for next step" + + +def test_concurrent_runs_keep_their_own_results(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + entered = threading.Event() + with ThreadPoolExecutor(max_workers=2) as workers: + first = workers.submit(session.run, "hold-prompt", on_notification=lambda notice: entered.set()) + try: + assert entered.wait(5), "the first run must observe its receipt" + second = workers.submit(session.run, "/compact") + assert isinstance(second.result(timeout=5), CommandExecution) + finally: + release(sdk) + result = first.result(timeout=5) + assert isinstance(result, RunResult) + assert result.final_response == "answer for hold-prompt" + assert_released(sdk) + + +def test_command_result_needs_no_notifications(sdk: DeepSeekHarness) -> None: + notices = [] + result = sdk.start_session("main").run("silent-command", on_notification=notices.append) + assert isinstance(result, CommandExecution) + assert notices == [] + assert_released(sdk) + + +@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError)]) +def test_run_reports_routing_failures_without_retry(sdk: DeepSeekHarness, text: str, error: type[Exception]) -> None: + session = sdk.start_session("main") + with pytest.raises(error): + session.run(text) + assert_released(sdk) + assert isinstance(session.run("/compact"), CommandExecution) + + +@pytest.mark.parametrize("text", ["ordinary", "/compact"]) +def test_observer_error_releases_run_subscription(sdk: DeepSeekHarness, text: str) -> None: + def reject(_notice: Notification) -> None: + raise RuntimeError("observer failed") + with pytest.raises(RuntimeError, match="observer failed"): + sdk.start_session("main").run(text, on_notification=reject) + assert_released(sdk) + assert isinstance(sdk.start_session("main").run("after observer"), RunResult) + + +def test_command_timeout_keeps_runtime_responsive(sdk: DeepSeekHarness) -> None: + sdk.client.config.request_timeout_seconds = 0.05 + with pytest.raises(TimeoutError, match="session/query timed out"): + sdk.start_session("main").run("hold-command") + sdk.client.config.request_timeout_seconds = 5 + assert_released(sdk) + release(sdk) + assert isinstance(sdk.start_session("main").run("/compact"), CommandExecution) + + +def test_run_requires_supported_delivery(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + with pytest.raises(ValueError, match="delivery"): + session.run("ordinary", delivery="unknown") + with pytest.raises(ValueError, match="content-block"): + session.run([{"type": "text", "text": "ordinary"}], delivery="steer") diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 08cdf6fe57..546bc15574 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -858,12 +858,8 @@ async function runScenario(scenario: CorpusScenario): Promise<{ let session = harness.session(sessionId) for (const action of actions) { if (action.kind === 'command') { - await session.query(action.line) - let execution: CommandExecution | undefined - for await (const message of session.receiveResponse()) { - if ('commandId' in message) execution = message - } - if (execution === undefined) throw new Error('SDK command query returned no command result') + const execution = await session.run(action.line) + if (!('commandId' in execution)) throw new Error('SDK command input returned prompt activity') collectCommandNotifications(subscription, sessionId, execution, notifications, observe) continue } @@ -894,17 +890,16 @@ async function runScenario(scenario: CorpusScenario): Promise<{ if (assertions.replayStandaloneCommands === true) { const content = action.content[0] if (action.content.length !== 1 || content?.type !== 'text' || typeof content.text !== 'string') { - throw new Error(`${scenario.name} query fixture requires one text block per prompt`) + throw new Error(`${scenario.name} run fixture requires one text block per prompt`) } - await session.query(content.text) - for await (const message of session.receiveResponse()) { - if ('finalResponse' in message) result = message - else if ('method' in message) { + const outcome = await session.run(content.text, { + onNotification(message) { notifications.push(message) observe(message) - } - } - if (result === undefined) throw new Error('SDK prompt query returned no activity result') + }, + }) + if ('commandId' in outcome) throw new Error('SDK prompt input returned a command result') + result = outcome } else { result = await session.run(materializeInput(action.content, scenario, cwd, liveSessions), { onNotification: (notification) => {