refactor(sdk): simplify resume acknowledgement and verify Python restoration

This commit is contained in:
fz committed 2026-10-10 15:21:40 +08:00
1 parent 4f2fc8935a
commit b1d5671038
25 files changed
+122 -98

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-sdk-client/use-this-package:
en: 9e138ef9e502c745
zh: 8641b6976252843b
en: ff421f12a5db28a4
zh: 2ca9b71a3458b5e0
/deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness:
en: 01c58dddd7e62de2
zh: 6dbd46e244f3f135
+1 -1
View File
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. The low-level client exposes `resumeSession(id)` and returns the restored identity. Shell, browser, and other external processes are not restored by this API.
`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. Shell, browser, and other external processes are not restored by this API.
`HarnessSession.getWorkingDirectory()` reads the effective directory; `setWorkingDirectory(path)` changes it and returns the validated absolute path. Either method creates an unknown Session without starting a model turn. Relative paths use the Session's current directory. Origin metadata, permissions, and existing processes remain unchanged. `HarnessClient` exposes the same methods with a leading `sessionId` argument.
+1 -1
View File
@@ -25,7 +25,7 @@ kind: "package-library"
<a id="use-this-package"></a>
## 使用本包
`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。底层客户端提供 `resumeSession(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。
`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。
`HarnessSession.getWorkingDirectory()` 读取有效目录;`setWorkingDirectory(path)` 修改目录并返回校验后的绝对路径。两个方法都可创建未知 Session,而不启动模型轮次。相对路径以 Session 当前目录解析。起始目录元数据、权限与已有进程保持不变。`HarnessClient` 提供同名方法,并以 `sessionId` 作为首个参数。
+2 -2
View File
@@ -112,8 +112,8 @@ export class DeepSeekHarness implements AsyncDisposable {
*/
async resumeSession(sessionId: string): Promise<HarnessSession> {
await this.start()
const restoredId = await this.client.resumeSession(sessionId)
return new HarnessSession(this, restoredId)
await this.client.resumeSession(sessionId)
return new HarnessSession(this, sessionId)
}
/**
+3 -7
View File
@@ -300,14 +300,10 @@ export class HarnessClient {
/**
* Restore persisted history without sending a prompt; failures propagate without creating a session.
* @param sessionId - persisted identity in this runtime's configured persistence store.
* @returns the restored session identity.
* @returns after the session is ready for prompts.
*/
async resumeSession(sessionId: string): Promise<string> {
const result = await this.request('session/resume', { sessionId })
if (!isRecord(result) || result.sessionId !== sessionId) {
throw new SdkProtocolError(`session/resume returned an unexpected session id: ${JSON.stringify(result)}`)
}
return sessionId
async resumeSession(sessionId: string): Promise<void> {
await this.request('session/resume', { sessionId })
}
/**
+1 -1
View File
@@ -287,7 +287,7 @@ reader.on('line', (line) => {
if (env.FAKE_RESUME_ERROR !== undefined) {
write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: 'session not found' } })
} else {
respond({ sessionId: env.FAKE_MALFORMED_RESUME === undefined ? sessionIdOf(frame.params) : 'wrong-session' })
respond({})
}
return
case 'session/working-directory/get':
+1 -3
View File
@@ -70,11 +70,9 @@ describe('DeepSeekHarness', () => {
expect((await session.run('continue')).sessionId).toBe('saved-session')
})
it('propagates resume failures and rejects mismatched response identities', async () => {
it('propagates resume failures', async () => {
await expect(harnessWith({ FAKE_RESUME_ERROR: '1' }).resumeSession('missing'))
.rejects.toThrow('session not found')
await expect(harnessWith({ FAKE_MALFORMED_RESUME: '1' }).resumeSession('saved'))
.rejects.toThrow('unexpected session id')
})
it('reads and changes directories independently for each lazy Session', async () => {
+2 -2
View File
@@ -21,8 +21,8 @@
en: be53c71505c0d1b4
zh: 6210dee577715539
/deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods:
en: 8b1665958ba49b09
zh: 73a29017ac9b0496
en: 8057053746fc2755
zh: 36d5989488c1a351
/deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics:
en: 3cf5d4ffbaf37f69
zh: 23c4450e31c91d67
+2 -2
View File
@@ -39,7 +39,7 @@ Both wire ends share one method set: seven client-to-server requests and four se
|---|---|---|
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) |
| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` |
| client→server | `session/resume` | `SessionResumeParams` → `{}` |
| client→server | `session/wait` | `SessionWaitParams` → `{}` |
| client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` |
| client→server | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` |
@@ -53,7 +53,7 @@ Both wire ends share one method set: seven client-to-server requests and four se
`session/wait` accepts `{ sessionId }` for an existing SDK-owned session and returns `{}` after the root stays idle and progressing managed descendant work finishes. Idle descendants with parked input remain resident without blocking this wait. Unknown ids and live Agent failures without a newer committed terminal are rejected. Notifications committed during the wait precede its response on the same transport; raw `session.status` transitions retain their whole-agent meaning.
`session/resume` accepts a non-empty persisted `{ sessionId }` and returns `{ sessionId }` after restoring that exact session. It requires initialization and the original persistence store, sends no new input, and rejects missing, unreadable, incompatible, or occupied sessions without creating a replacement. Historical events remain in the stored log; notifications report newly committed events.
`session/resume` accepts a non-empty persisted `{ sessionId }` and returns `{}` after restoring that exact session. It requires initialization and the original persistence store, sends no new input, and rejects missing, unreadable, incompatible, or occupied sessions without creating a replacement. Historical events remain in the stored log; notifications report newly committed events.
### Payload semantics
+2 -2
View File
@@ -39,7 +39,7 @@ kind: "package-library"
|---|---|---|
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) |
| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` |
| client→server | `session/resume` | `SessionResumeParams` → `{}` |
| client→server | `session/wait` | `SessionWaitParams` → `{}` |
| 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` |
| 客户端→服务端 | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` |
@@ -53,7 +53,7 @@ kind: "package-library"
`session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。
`session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{ sessionId }`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。
`session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{}`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。
### 载荷语义
-1
View File
@@ -23,7 +23,6 @@ export type {
SessionPromptParams,
SessionPromptResult,
SessionResumeParams,
SessionResumeResult,
SessionWaitParams,
SessionWorkingDirectoryParams,
SessionWorkingDirectorySetParams,
+1 -7
View File
@@ -64,12 +64,6 @@ export interface SessionResumeParams {
sessionId: string
}
/** Identity of the session successfully restored into the SDK runtime. */
export interface SessionResumeResult {
/** The same persisted identity requested by the caller. */
sessionId: string
}
/** Wait for an existing SDK session and its managed descendants to finish. */
export interface SessionWaitParams {
/** An SDK-owned session id; waiting never creates a session. */
@@ -151,7 +145,7 @@ export interface HarnessSdkNotificationMap {
export interface HarnessSdkRequestMap {
'initialize': { params: InitializeParams; result: InitializeResult }
'session/prompt': { params: SessionPromptParams; result: SessionPromptResult }
'session/resume': { params: SessionResumeParams; result: SessionResumeResult }
'session/resume': { params: SessionResumeParams; result: Record<string, never> }
'session/wait': { params: SessionWaitParams; result: Record<string, never> }
'session/working-directory/get': { params: SessionWorkingDirectoryParams; result: SessionWorkingDirectoryResult }
'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult }
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package:
en: aeb6fcf86da385f6
zh: 7d294651bd621ee7
en: e136ff3a0b42cc22
zh: f6c4df5bbafa335f
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring:
en: 47a33e9397e36e15
zh: e6d173820f9ea6e5
+1 -1
View File
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
`session/resume` accepts a persisted `{ sessionId }` after initialization and returns the same identity once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while applying the initialized model route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications.
`session/resume` accepts a persisted `{ sessionId }` after initialization and returns `{}` once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while applying the initialized model route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications.
`session/working-directory/get` accepts `{ sessionId }`; `session/working-directory/set` accepts `{ sessionId, path }`. Both return `{ cwd }` and create an unknown Session without starting a model turn. Reads recover missing directories through the working-directory service; changes preserve origin metadata and permissions, and reach the model through logged user context.
+1 -1
View File
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回同一标识。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。
`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回 `{}`。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。
`session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。
+3 -4
View File
@@ -25,7 +25,6 @@ import type {
SessionPromptParams,
SessionPromptResult,
SessionResumeParams,
SessionResumeResult,
SessionWaitParams,
SessionWorkingDirectoryParams,
SessionWorkingDirectorySetParams,
@@ -218,9 +217,9 @@ export class HarnessSdkJsonRpcServer {
* Uses the initialized model route while preserving the stored session identity and directory.
* Rejects missing, unreadable, or already owned sessions; never creates a replacement.
* @param params - persisted session identity in the configured persistence store.
* @returns the restored identity after the Agent is ready for prompts.
* @returns an empty result after the Agent is ready for prompts.
*/
async resume(params: SessionResumeParams): Promise<SessionResumeResult> {
async resume(params: SessionResumeParams): Promise<Record<string, never>> {
if (!this.initialized) throw new Error('SDK server is not initialized')
if (this.shuttingDown) throw new Error('SDK server is shutting down')
const sessionId = params.sessionId
@@ -228,7 +227,7 @@ export class HarnessSdkJsonRpcServer {
throw new Error(`SDK session is already open or opening: ${sessionId}`)
}
await this.openSession(sessionId, true)
return { sessionId }
return {}
}
/**
+10 -4
View File
@@ -7,7 +7,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol'
import type { JsonRpcTransportPeer, SessionEventNotification } from '@deepseek-ai/dsh-sdk-protocol'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { HarnessSdkJsonRpcServer } from '../src/index.ts'
@@ -30,13 +30,16 @@ async function runtime(root: string, persistence = true) {
await ctx.plugin(AgentLoop, { agents: [] })
const adapter = new MockAdapter([textResponse('remembered')])
ctx.llm.registerAdapter(['mock'], adapter)
const events: SessionEventNotification[] = []
const transport: JsonRpcTransportPeer = {
request: async () => { throw new Error('unexpected server request') },
notify: () => {},
notify: (method, params) => {
if (method === 'session.event') events.push(params as SessionEventNotification)
},
}
const server = new HarnessSdkJsonRpcServer(ctx, transport)
cleanups.push(() => server.shutdown().then(() => {}))
return { ctx, server, adapter }
return { ctx, server, adapter, events }
}
async function initialized(root: string) {
@@ -58,14 +61,17 @@ describe('SDK persisted session resume', () => {
const second = await runtime(root)
await second.server.initialize({ cwd: tmpdir(), provider: 'mock', model: 'resumed-model', maxTokens: 123 })
await expect(second.server.handleRequest('session/resume', { sessionId: 'saved-session' }))
.resolves.toEqual({ sessionId: 'saved-session' })
.resolves.toEqual({})
const restored = second.ctx.agents.get(SessionId('saved-session'))!.session
expect(restored.header.cwd).toBe(root)
expect(restored.snapshotEvents().slice(0, original.length)).toEqual(original)
expect(restored.snapshotEvents().at(-1)).toMatchObject({ type: 'session/end-seed' })
expect(second.adapter.requests).toHaveLength(0)
expect(second.events).toEqual([])
await second.server.prompt({ sessionId: 'saved-session', contentBlocks: [{ type: 'text', text: 'what did I say?' }] })
await second.server.wait({ sessionId: 'saved-session' })
expect(second.events.length).toBeGreaterThan(0)
expect(second.events.every(notification => notification.event.seq > original.at(-1)!.seq)).toBe(true)
const request = JSON.stringify(second.adapter.requests[0])
expect(request).toContain('remember kiwi')
expect(request).toContain('remembered')
+4 -4
View File
@@ -9,11 +9,11 @@
en: ddf4dabea526cef1
zh: 6e31cab10d2f3ed8
/deepseek-harness-python-sdk/start-a-runtime:
en: 9a069c1a7cbb0779
zh: 7f625a802cd1e9ea
en: 5fceaffcc35039c6
zh: 1d87e3defe7e875b
/deepseek-harness-python-sdk/customize-plugins:
en: a6f2fcfa7ba8a050
zh: 60fdca6dc0594c79
en: d32ee602f121e6af
zh: fdea4487688e5774
/deepseek-harness-python-sdk/results-and-notifications:
en: 173d2dc1ce63fe2d
zh: 5354e18c59f20878
+8 -8
View File
@@ -50,6 +50,14 @@ print(result.final_response)
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
`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.
```py
with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
session = harness.resume_session(saved_session_id)
result = session.run("Continue the task.")
```
## Customize plugins
Persistent customization belongs to a `dsh` profile. Initialize the shipped SDK profile and install an external bundle with the runtime wheel's `dsh` command:
@@ -79,14 +87,6 @@ with DeepSeekHarness(
The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides only a platform-selected persistent shell, local execution, and JSONL sessions; filesystem tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles.
`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. The low-level client also exposes `resume_session(id)`, returning the restored identity. This API does not restore shell, browser, or other external processes.
```py
with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
session = harness.resume_session(saved_session_id)
result = session.run("Continue the task.")
```
## Results and notifications
`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`.
+8 -8
View File
@@ -50,6 +50,14 @@ print(result.final_response)
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。
`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。
```py
with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
session = harness.resume_session(saved_session_id)
result = session.run("Continue the task.")
```
## 自定义插件
持久自定义属于 `dsh` profile。使用运行时 wheel 包提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle:
@@ -79,14 +87,6 @@ with DeepSeekHarness(
随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它只提供按平台选择的持久 shell、本地执行与 JSONL 会话;文件系统工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。
`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。底层客户端也提供 `resume_session(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。
```py
with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
session = harness.resume_session(saved_session_id)
result = session.run("Continue the task.")
```
## 结果与通知
`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。
+3 -3
View File
@@ -124,11 +124,11 @@ class DeepSeekHarness:
def resume_session(self, session_id: str) -> "Session":
"""Restore persisted history from the configured Harness home without sending input.
Missing, unreadable, or occupied sessions raise instead of creating a new session.
Missing, unreadable, incompatible, or occupied sessions raise instead of creating a new session.
"""
self.start()
restored_id = self._client.resume_session(session_id)
return Session(self, restored_id)
self._client.resume_session(session_id)
return Session(self, session_id)
def run(
self,
+5 -12
View File
@@ -98,7 +98,7 @@ class HarnessClient:
return
shutdown_completed = False
try:
self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds)
self.request("shutdown", None, response_model=_EmptyResponse, timeout_seconds=self.config.shutdown_timeout_seconds)
shutdown_completed = True
except Exception as exc:
self._stderr_lines.append(f"shutdown request failed: {exc}")
@@ -188,16 +188,13 @@ class HarnessClient:
)
return response.messageId
def resume_session(self, session_id: str) -> str:
def resume_session(self, session_id: str) -> None:
"""Restore a persisted session without sending a prompt; propagate restoration failures."""
response = self.request(
self.request(
"session/resume",
{"sessionId": session_id},
response_model=_SessionResumeResponse,
response_model=_EmptyResponse,
)
if response.sessionId != session_id:
raise SdkProtocolError("session/resume returned an unexpected session id")
return response.sessionId
def get_working_directory(self, session_id: str) -> str:
"""Read the effective directory, recovering a missing directory to the Session origin."""
@@ -610,15 +607,11 @@ class _SessionPromptResponse(BaseModel):
messageId: str
class _SessionResumeResponse(BaseModel):
sessionId: str
class _WorkingDirectoryResponse(BaseModel):
cwd: str
class _ShutdownResponse(BaseModel):
class _EmptyResponse(BaseModel):
pass
+5 -8
View File
@@ -6,12 +6,12 @@ from pathlib import Path
import pytest
from deepseek_harness import DeepSeekHarness, SdkProtocolError
from deepseek_harness import DeepSeekHarness
from deepseek_harness.errors import JsonRpcError
@pytest.mark.parametrize("outcome", ["restored", "missing", "wrong-id"])
def test_resume_checks_identity_and_propagates_failure(tmp_path: Path, outcome: str) -> None:
@pytest.mark.parametrize("outcome", ["restored", "missing"])
def test_resume_preserves_identity_and_propagates_failure(tmp_path: Path, outcome: str) -> None:
script = tmp_path / "runtime.py"
requests = tmp_path / "requests.jsonl"
script.write_text(
@@ -33,7 +33,7 @@ for line in sys.stdin:
if outcome == "missing":
response["error"] = {"code": -32603, "message": "session not found"}
else:
response["result"] = {"sessionId": "saved" if outcome == "restored" else "wrong"}
response["result"] = {}
else:
response["result"] = {}
print(json.dumps(response), flush=True)
@@ -50,11 +50,8 @@ for line in sys.stdin:
session = harness.resume_session("saved")
assert session.id == "saved"
assert session.harness is harness
elif outcome == "missing":
with pytest.raises(JsonRpcError, match="session not found"):
harness.resume_session("saved")
else:
with pytest.raises(SdkProtocolError, match="unexpected session id"):
with pytest.raises(JsonRpcError, match="session not found"):
harness.resume_session("saved")
frames = [json.loads(line) for line in requests.read_text(encoding="utf-8").splitlines()]
assert [frame["method"] for frame in frames] == ["initialize", "session/resume", "shutdown"]
+52 -7
View File
@@ -104,6 +104,8 @@ RESTART_SECOND_PROMPT = "Complete the second isolated Python SDK process turn."
RESTART_SECOND_TEXT = "PROCESS_TWO_OK"
RESTART_FIRST_SESSION_ID = "process-one"
RESTART_SECOND_SESSION_ID = "process-two"
RESUME_PROMPT = "Continue the first Python SDK session after restarting the runtime."
RESUME_TEXT = "RESUMED_PROCESS_OK"
RECOVERY_PROMPT = "Run three todo updates; the scheduler will reject the second call."
RECOVERY_CONTINUE_PROMPT = "Continue after the scheduler failure without repeating any tools."
RECOVERY_TEXT = "SCHEDULER_RECOVERY_OK"
@@ -429,6 +431,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
MCP_PROMPT,
RESTART_FIRST_PROMPT,
RESTART_SECOND_PROMPT,
RESUME_PROMPT,
PROFILE_PLUGIN_PROMPT,
AUTHORING_PROMPT,
DYNAMIC_TOOLS_PROMPT,
@@ -457,6 +460,8 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
return tool_call_chunks("advanced-native", "snapshot_double", {"value": 21})
if prompt == RESTART_FIRST_PROMPT:
return text_chunks(RESTART_FIRST_TEXT)
if prompt == RESUME_PROMPT:
return text_chunks(RESUME_TEXT)
if prompt == DYNAMIC_TOOLS_PROMPT:
assert_advertised_tool(body, "read")
if "snapshot_ping" in advertised_tool_names(body):
@@ -1694,7 +1699,7 @@ def smoke_sdk_snapshot_mode(
def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Snapshot two isolated sessions across complete SDK runtime restarts."""
"""Check isolated sessions and explicit history restoration across runtime restarts."""
from deepseek_harness import DeepSeekHarness
with tempfile.TemporaryDirectory(prefix="dsh-sdk-restart-") as temporary:
@@ -1704,8 +1709,8 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
patch = write_advanced_profile_patch(root, "restart.patch.yml", sessions)
first_request = len(MockModelHandler.requests)
def run(prompt: str, session_id: str) -> "RunResult":
with DeepSeekHarness(
def connect() -> DeepSeekHarness:
return DeepSeekHarness(
provider="deepseek-official",
model="smoke-model",
cwd=str(root),
@@ -1719,11 +1724,12 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
api_key="sk-keyless-smoke",
base_url=base_url,
request_timeout_seconds=60,
) as harness:
return harness.run(prompt, session_id=session_id)
)
first = run(RESTART_FIRST_PROMPT, RESTART_FIRST_SESSION_ID)
second = run(RESTART_SECOND_PROMPT, RESTART_SECOND_SESSION_ID)
with connect() as harness:
first = harness.run(RESTART_FIRST_PROMPT, session_id=RESTART_FIRST_SESSION_ID)
with connect() as harness:
second = harness.run(RESTART_SECOND_PROMPT, session_id=RESTART_SECOND_SESSION_ID)
requests = MockModelHandler.requests[first_request:]
if len(requests) != 2:
raise AssertionError(f"restart snapshot expected two model requests: {requests}")
@@ -1759,6 +1765,45 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
native_writer_output=True,
)
before_resume = logs[RESTART_FIRST_SESSION_ID]
with connect() as harness:
session = harness.resume_session(RESTART_FIRST_SESSION_ID)
if session.id != RESTART_FIRST_SESSION_ID:
raise AssertionError(f"resume changed the saved identity: {session.id}")
if len(MockModelHandler.requests) != first_request + 2:
raise AssertionError("resume sent a model request before receiving new input")
resumed = session.run(RESUME_PROMPT)
requests = MockModelHandler.requests[first_request + 2:]
if len(requests) != 1 or resumed.final_response != RESUME_TEXT:
raise AssertionError(f"resumed turn failed: {resumed.final_response!r}, {requests}")
messages = requests[0].get("messages")
if not isinstance(messages, list):
raise AssertionError(f"resumed model request has no messages: {requests[0]}")
for role, expected in (
("user", RESTART_FIRST_PROMPT),
("assistant", RESTART_FIRST_TEXT),
("user", RESUME_PROMPT),
):
if not any(
isinstance(message, dict)
and message.get("role") == role
and expected in message_text(message.get("content"))
for message in messages
):
raise AssertionError(f"resumed model history has no {role} message: {expected}")
logs = read_session_logs(sessions)
if set(logs) != expected_ids:
raise AssertionError(f"resume created an unexpected durable session: {sorted(logs)}")
restored = logs[RESTART_FIRST_SESSION_ID]
if restored[:len(before_resume)] != before_resume:
raise AssertionError("resume replaced the saved session log instead of appending")
turns = [record for record in restored if record.get("type") == "turn/end"]
if [record["data"]["turn"] for record in turns] != [1, 2]:
raise AssertionError(f"resume did not continue the saved turn sequence: {turns}")
if RESUME_TEXT not in render_jsonl(restored):
raise AssertionError("resumed response was not appended to the saved session")
def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Keep a failed tool turn usable through the real Messages serializer and SDK."""
+2 -5
View File
@@ -101,8 +101,6 @@ function dirOf(url: string): string {
}
interface SdkAssertions {
/** Reap the runtime between user turns and restore the same persisted Session. */
restartBetweenTurns?: boolean
/** Additional profile patches applied after the shared composition. */
patches?: readonly string[]
/** Wait for the recorded provider title before sending each subsequent prompt. */
@@ -129,7 +127,6 @@ interface SdkAssertions {
}
const SDK_ASSERTIONS: Readonly<Record<string, SdkAssertions>> = {
'session-resume': { restartBetweenTurns: true },
'session-title-stability': { waitForProviderTitle: true },
'dynamic-tool-updates': {
expectedFinalResponse: 'DONE',
@@ -603,7 +600,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{
})
const [parentFixture, ...childFixtures] = replayFixtures
const actions = turnActions(primaryFixture)
const resumeReplayFile = assertions.restartBetweenTurns === true && !recording
const resumeReplayFile = scenario.name === 'session-resume' && !recording
? join(patchRoot, 'resume-replay.json') : undefined
const prepareTurnReplay = async (turn: number): Promise<void> => {
if (resumeReplayFile === undefined || parentFixture === undefined) return
@@ -681,7 +678,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{
)
continue
}
if (assertions.restartBetweenTurns === true && results.length > 0) {
if (scenario.name === 'session-resume' && results.length > 0) {
subscription.close()
await harness.close()
await prepareTurnReplay(action.turn)