From 0bec9479aea3651479ea52a7569e2717e6c02d76 Mon Sep 17 00:00:00 2001 From: fz Date: Sat, 10 Oct 2026 13:07:36 +0800 Subject: [PATCH 1/3] feat(sdk): add explicit persisted session resume --- packages/sdk/client/README.i18n.yaml | 4 +- packages/sdk/client/README.md | 2 + packages/sdk/client/README.zh.md | 2 + packages/sdk/client/src/api.ts | 12 ++ packages/sdk/client/src/client.ts | 13 ++ packages/sdk/client/tests/fake-runtime.ts | 7 + packages/sdk/client/tests/sdk-client.spec.ts | 14 ++ packages/sdk/protocol/README.i18n.yaml | 4 +- packages/sdk/protocol/README.md | 5 +- packages/sdk/protocol/README.zh.md | 5 +- packages/sdk/protocol/src/index.ts | 2 + packages/sdk/protocol/src/types.ts | 13 ++ packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 + packages/sdk/server/README.zh.md | 2 + packages/sdk/server/src/server.ts | 55 ++++- packages/sdk/server/tests/resume.spec.ts | 198 ++++++++++++++++++ python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 8 + python/sdk/README.zh.md | 8 + python/sdk/src/deepseek_harness/api.py | 9 + python/sdk/src/deepseek_harness/client.py | 17 +- python/sdk/tests/test_resume.py | 61 ++++++ snapshots/sdk/sdk.snapshot.ts | 35 +++- snapshots/sdk/session-resume/session.v4.jsonl | 27 +++ snapshots/sdk/session-resume/snapshot.yml | 7 + 26 files changed, 495 insertions(+), 25 deletions(-) create mode 100644 packages/sdk/server/tests/resume.spec.ts create mode 100644 python/sdk/tests/test_resume.py create mode 100644 snapshots/sdk/session-resume/session.v4.jsonl create mode 100644 snapshots/sdk/session-resume/snapshot.yml diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index a54c75363b..275bb84fc6 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-client/use-this-package: - en: 316b142489dd050f - zh: 2b37ae57b2a52b5e + en: 9e138ef9e502c745 + zh: 8641b6976252843b /deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness: en: 01c58dddd7e62de2 zh: 6dbd46e244f3f135 diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 4757f2742c..05b5de7825 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -25,6 +25,8 @@ English | [中文](README.zh.md) ## 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. + `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. Use this client when TypeScript code must drive a complete Harness runtime from another process and you can name the runtime executable explicitly. The common path is minimal: construct a `DeepSeekHarness` with a launch spec, run prompts, and close it so the child process is always reaped. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index df156c444d..e74f0e1119 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -25,6 +25,8 @@ kind: "package-library" ## 使用本包 +`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。底层客户端提供 `resumeSession(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。 + `HarnessSession.getWorkingDirectory()` 读取有效目录;`setWorkingDirectory(path)` 修改目录并返回校验后的绝对路径。两个方法都可创建未知 Session,而不启动模型轮次。相对路径以 Session 当前目录解析。起始目录元数据、权限与已有进程保持不变。`HarnessClient` 提供同名方法,并以 `sessionId` 作为首个参数。 当 TypeScript 代码需要从另一进程驱动完整 Harness 运行时、且你能显式指名运行时可执行文件时,使用本客户端。常用路径极简:用启动规格构造 `DeepSeekHarness`,运行提示词,然后关闭它,使子进程总能被回收。 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 63fd7263b3..010e532755 100644 --- a/packages/sdk/client/src/api.ts +++ b/packages/sdk/client/src/api.ts @@ -104,6 +104,18 @@ export class DeepSeekHarness implements AsyncDisposable { return new HarnessSession(this, sessionId ?? `session-${randomUUID().replaceAll('-', '')}`) } + /** + * Start the runtime and restore a persisted session without sending input. + * The configured Harness home must contain its history; missing or occupied sessions reject. + * @param sessionId - exact persisted identity to continue. + * @returns a handle ready to run new prompts on the restored history. + */ + async resumeSession(sessionId: string): Promise { + await this.start() + const restoredId = await this.client.resumeSession(sessionId) + return new HarnessSession(this, restoredId) + } + /** * Run one prompt on a fresh (or named) session. * @param input - prompt text, or content blocks sent verbatim. diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index c20356dd0b..93b90f379e 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -297,6 +297,19 @@ export class HarnessClient { return result.messageId } + /** + * 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. + */ + async resumeSession(sessionId: string): Promise { + 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 + } + /** * Read a Session's effective working directory, recovering it if missing. * @param sessionId - target Session; an unknown id creates it. diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index 8142573d58..14f1f9cbbc 100644 --- a/packages/sdk/client/tests/fake-runtime.ts +++ b/packages/sdk/client/tests/fake-runtime.ts @@ -283,6 +283,13 @@ reader.on('line', (line) => { } respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }) return + case 'session/resume': + 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' }) + } + return case 'session/working-directory/get': case 'session/working-directory/set': { if (env.FAKE_MALFORMED_DIRECTORY !== undefined) { diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 7dbfa38398..e7ea2a733c 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -63,6 +63,20 @@ async function tempDir(prefix: string): Promise { } describe('DeepSeekHarness', () => { + it('returns a restored handle that can run followups on the same identity', async () => { + 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') + }) + + it('propagates resume failures and rejects mismatched response identities', 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 () => { const harness = harnessWith() const first = harness.session('directory-first') diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 1c6cc84e11..c313108790 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -21,8 +21,8 @@ en: be53c71505c0d1b4 zh: 6210dee577715539 /deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods: - en: 0c7bea342e6a851c - zh: de38cf6fe1f4ae76 + en: 8b1665958ba49b09 + zh: 73a29017ac9b0496 /deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics: en: 3cf5d4ffbaf37f69 zh: 23c4450e31c91d67 diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 89bbe15040..5fee771642 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -33,12 +33,13 @@ Wire one JSON-RPC 2.0 message per `\n`-terminated line over byte streams you own ### The SDK methods -Both wire ends share one method set: six client-to-server requests and four server-to-client notifications. +Both wire ends share one method set: seven client-to-server requests and four server-to-client notifications. | Direction | Method | Payload types | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) | +| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | client→server | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | @@ -52,6 +53,8 @@ Both wire ends share one method set: six client-to-server requests and four serv `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. + ### Payload semantics `SessionPromptResult.messageId` identifies the queued user message; it does not identify a later assistant message, turn ending, or prompt result. `SdkPromptContentBlock` accepts ordinary durable content plus `SdkEncodedImageBlock { type: "image", data, mimeType }`; the server converts encoded images to durable references before enqueue. `InitializeParams.reasoningEffort` is an optional non-empty adapter-owned identifier for the selected provider/model route; omission preserves that model's default. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission lets the selected adapter's exact-model default apply. The server resolves the exact route during initialization and rejects `session/prompt` until that handshake succeeds, so a missing adapter, unavailable model, or unsupported effort cannot fall back to constructor defaults. `SubagentFinishedNotification.lastAssistantMessage` carries the child's last non-empty assistant message, or its accumulated assistant text when no such message exists; the field is absent when the child produced neither. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. Notification payloads depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`), so the session vocabulary is part of the wire contract. diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index 91c007c6a8..adac676e09 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -33,12 +33,13 @@ kind: "package-library" ### SDK 方法 -两个协议端共享同一套方法:六个客户端到服务端请求与四个服务端到客户端通知。 +两个协议端共享同一套方法:七个客户端到服务端请求与四个服务端到客户端通知。 | 方向 | 方法 | 载荷类型 | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) | +| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | 客户端→服务端 | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | @@ -52,6 +53,8 @@ kind: "package-library" `session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。 +`session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{ sessionId }`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 + ### 载荷语义 `SessionPromptResult.messageId` 标识已排队的用户消息;它不标识后续的助手消息、轮次结束或提示词结果。`SdkPromptContentBlock` 接受普通持久内容以及 `SdkEncodedImageBlock { type: "image", data, mimeType }`;服务器在入队前把编码图像转换为持久引用。`InitializeParams.reasoningEffort` 是所选提供方/模型路由可选的非空适配器自有标识符;省略时保留该模型的默认值。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时应用所选适配器的确切模型默认值。服务器会在初始化期间解析确切路由,并在握手成功前拒绝 `session/prompt`,因此缺少适配器、模型不可用或推理强度不受支持时,不会回退到构造期默认值。`SubagentFinishedNotification.lastAssistantMessage` 携带子 agent 最后一条非空 assistant 消息;若不存在这类消息,则携带其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。通知载荷依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`),因此会话词汇是协议格式约定的一部分。 diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts index 4780072f4a..d62985dea0 100644 --- a/packages/sdk/protocol/src/index.ts +++ b/packages/sdk/protocol/src/index.ts @@ -22,6 +22,8 @@ export type { SessionStatusNotification, SessionPromptParams, SessionPromptResult, + SessionResumeParams, + SessionResumeResult, SessionWaitParams, SessionWorkingDirectoryParams, SessionWorkingDirectorySetParams, diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index 9c2fd6335d..265bfee98b 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -58,6 +58,18 @@ export interface SessionPromptResult { messageId: string } +/** Restore a persisted session without enqueueing a prompt. */ +export interface SessionResumeParams { + /** Persisted session identity; missing or already owned sessions are rejected. */ + 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. */ @@ -139,6 +151,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/wait': { params: SessionWaitParams; result: Record } 'session/working-directory/get': { params: SessionWorkingDirectoryParams; result: SessionWorkingDirectoryResult } 'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 4265918980..c1fd3aea09 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: ab5b719668e746f2 - zh: 971c2fdaa9e7d351 + en: aeb6fcf86da385f6 + zh: 7d294651bd621ee7 /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 cc5061f3e4..9ed095df3c 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -25,6 +25,8 @@ English | [中文](README.zh.md) ## 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/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. Mount this plugin when a runtime must serve SDK clients: add it to a `cordis.yml` that composes the agent service, boot the runtime, and clients connect over stdio. The common path is explicit — the plugin needs the `agents` service; every other capability comes from the surrounding tree. diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index d724e220eb..077e2f167f 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回同一标识。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。 + `session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。 diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index ab5084ffb7..fe76002883 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -24,6 +24,8 @@ import type { SessionEventNotification, SessionPromptParams, SessionPromptResult, + SessionResumeParams, + SessionResumeResult, SessionWaitParams, SessionWorkingDirectoryParams, SessionWorkingDirectorySetParams, @@ -211,6 +213,24 @@ export class HarnessSdkJsonRpcServer { return { messageId: message.id } } + /** + * Restore a persisted session and repair its interrupted final turn without sending input. + * 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. + */ + async resume(params: SessionResumeParams): Promise { + 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 + if (this.sessions.has(sessionId) || this.sessionCreations.has(sessionId)) { + throw new Error(`SDK session is already open or opening: ${sessionId}`) + } + await this.openSession(sessionId, true) + return { sessionId } + } + /** * Wait for an existing owned Agent and its managed descendants to finish. * The response follows all Session notifications emitted during the wait. @@ -327,6 +347,13 @@ export class HarnessSdkJsonRpcServer { return this.initialize(params as unknown as InitializeParams) case 'session/prompt': return this.prompt(params as unknown as SessionPromptParams) + case 'session/resume': { + const sessionId = params?.sessionId + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new TypeError('session/resume requires a non-empty sessionId string') + } + return this.resume({ sessionId }) + } case 'session/wait': { const sessionId = params?.sessionId if (typeof sessionId !== 'string') throw new TypeError('session/wait requires a sessionId string') @@ -356,7 +383,11 @@ export class HarnessSdkJsonRpcServer { if (existing) return existing const pending = this.sessionCreations.get(sessionId) if (pending) return pending - const creation = this.createSession(sessionId) + return this.openSession(sessionId, false) + } + + private openSession(sessionId: string, resume: boolean): Promise { + const creation = this.createSession(sessionId, resume) this.sessionCreations.set(sessionId, creation) void creation.then( () => { this.sessionCreations.delete(sessionId) }, @@ -365,21 +396,25 @@ export class HarnessSdkJsonRpcServer { return creation } - private async createSession(sessionId: string): Promise { + private async createSession(sessionId: string, resume: boolean): Promise { // No preset composition: this server's compositions keep the model-facing // rows in the host plane, so this agent reads them from the global layer. A // deployment that configures a roster has to join one here first // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). - const handle = await this.ctx.agents.create({ + const agentOptions = { + provider: this.provider, + model: this.model, + ...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort }, + ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, + } + const handle = await (resume ? this.ctx.agents.resume({ + resumeSessionId: brandString(sessionId), + agentOptions, + }) : this.ctx.agents.create({ sessionId: brandString(sessionId), meta: { cwd: this.cwd }, - agentOptions: { - provider: this.provider, - model: this.model, - ...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort }, - ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, - }, - }) + agentOptions, + })) const rec: SessionRecord = { handle, failure: undefined } this.sessions.set(sessionId, rec) return rec diff --git a/packages/sdk/server/tests/resume.spec.ts b/packages/sdk/server/tests/resume.spec.ts new file mode 100644 index 0000000000..e0a7df70d9 --- /dev/null +++ b/packages/sdk/server/tests/resume.spec.ts @@ -0,0 +1,198 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +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 { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { HarnessSdkJsonRpcServer } from '../src/index.ts' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +async function storageRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'sdk-resume-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + return root +} + +async function runtime(root: string, persistence = true) { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + if (persistence) await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new MockAdapter([textResponse('remembered')]) + ctx.llm.registerAdapter(['mock'], adapter) + const transport: JsonRpcTransportPeer = { + request: async () => { throw new Error('unexpected server request') }, + notify: () => {}, + } + const server = new HarnessSdkJsonRpcServer(ctx, transport) + cleanups.push(() => server.shutdown().then(() => {})) + return { ctx, server, adapter } +} + +async function initialized(root: string) { + const result = await runtime(root) + await result.server.initialize({ cwd: root, provider: 'mock', model: 'mock' }) + return result +} + +describe('SDK persisted session resume', () => { + it('restores a non-main identity, preserves history and origin, and appends a new turn', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'saved-session', contentBlocks: [{ type: 'text', text: 'remember kiwi' }] }) + await first.server.wait({ sessionId: 'saved-session' }) + const original = first.ctx.agents.get(SessionId('saved-session'))!.session.snapshotEvents() + await first.server.shutdown() + await first.ctx.fiber.dispose() + + 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' }) + 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) + await second.server.prompt({ sessionId: 'saved-session', contentBlocks: [{ type: 'text', text: 'what did I say?' }] }) + await second.server.wait({ sessionId: 'saved-session' }) + const request = JSON.stringify(second.adapter.requests[0]) + expect(request).toContain('remember kiwi') + expect(request).toContain('remembered') + expect(request).toContain('what did I say?') + expect(second.adapter.requests[0]).toMatchObject({ model: 'resumed-model', maxTokens: 123 }) + expect(restored.snapshotEvents().slice(0, original.length)).toEqual(original) + expect(restored.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(2) + await expect(second.server.resume({ sessionId: 'saved-session' })).rejects.toThrow('already open') + }) + + it('repairs an interrupted final turn before accepting new input', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'interrupted', contentBlocks: [{ type: 'text', text: 'first turn' }] }) + await first.server.wait({ sessionId: 'interrupted' }) + await first.server.shutdown() + const handle = await first.ctx.sessionPersistence.open(SessionId('interrupted'), 'write') + try { + const events = (await handle.read()).events + const last = events.at(-1)! + await handle.append([ + { type: 'turn/start', data: { turn: 2 }, seq: SessionSeq(last.seq + 1), time: last.time + 1 }, + { type: 'step/start', data: { turn: 2, step: 1 }, seq: SessionSeq(last.seq + 2), time: last.time + 2 }, + ]) + await handle.flush() + } finally { + await handle.close() + } + await first.ctx.fiber.dispose() + const second = await initialized(root) + await second.server.resume({ sessionId: 'interrupted' }) + const restored = second.ctx.agents.get(SessionId('interrupted'))!.session + expect(restored.snapshotEvents().filter(event => event.type === 'turn/end').at(-1)).toMatchObject({ + type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } }, + }) + await second.server.prompt({ sessionId: 'interrupted', contentBlocks: [{ type: 'text', text: 'continue' }] }) + await second.server.wait({ sessionId: 'interrupted' }) + expect(restored.snapshotEvents().at(-1)).toMatchObject({ + type: 'turn/end', data: { turn: 3, reason: { kind: 'completed' } }, + }) + }) + + it('rejects missing history and releases a failed resume for retry', async () => { + const { ctx, server } = await initialized(await storageRoot()) + await expect(server.resume({ sessionId: 'missing' })).rejects.toThrow() + await expect(server.resume({ sessionId: 'missing' })).rejects.toThrow() + expect(ctx.agents.list()).toHaveLength(0) + expect(ctx.sessions.get(SessionId('missing'))).toBeUndefined() + }) + + it('shares pending restoration with a prompt and rejects duplicate resume', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'original' }] }) + await first.server.wait({ sessionId: 'saved' }) + await first.server.shutdown() + await first.ctx.fiber.dispose() + const { ctx, server } = await initialized(root) + const actualOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + const gate = Promise.withResolvers() + const opened = Promise.withResolvers() + const open = vi.spyOn(ctx.sessionPersistence, 'open').mockImplementation(async (...args) => { + opened.resolve(undefined) + await gate.promise + return actualOpen(...args) + }) + try { + const restoring = server.resume({ sessionId: 'saved' }) + await opened.promise + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('already open or opening') + const prompting = server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'followup' }] }) + gate.resolve(undefined) + await restoring + await prompting + await server.wait({ sessionId: 'saved' }) + expect(open).toHaveBeenCalledTimes(1) + expect(ctx.agents.list()).toHaveLength(1) + await server.shutdown() + expect(ctx.agents.list()).toHaveLength(0) + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('shutting down') + } finally { + gate.resolve(undefined) + } + }) + + it('waits for pending restoration during shutdown and releases the restored Agent', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'original' }] }) + await first.server.wait({ sessionId: 'saved' }) + await first.server.shutdown() + await first.ctx.fiber.dispose() + const { ctx, server } = await initialized(root) + const actualOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + const gate = Promise.withResolvers() + const opened = Promise.withResolvers() + vi.spyOn(ctx.sessionPersistence, 'open').mockImplementation(async (...args) => { + opened.resolve(undefined) + await gate.promise + return actualOpen(...args) + }) + try { + const restoring = server.resume({ sessionId: 'saved' }) + await opened.promise + const shutdown = server.shutdown() + await expect(server.resume({ sessionId: 'another' })).rejects.toThrow('shutting down') + gate.resolve(undefined) + await Promise.all([restoring, shutdown]) + expect(ctx.agents.list()).toHaveLength(0) + } finally { + gate.resolve(undefined) + } + }) + + it('rejects restoration when the profile has no persistence backend', async () => { + const root = await storageRoot() + const { ctx, server } = await runtime(root, false) + await server.initialize({ cwd: root, provider: 'mock', model: 'mock' }) + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('session persistence is not configured') + expect(ctx.agents.list()).toHaveLength(0) + }) + + it('validates the wire identity and requires initialization', async () => { + const { server } = await runtime(await storageRoot()) + for (const sessionId of [undefined, '', 17]) { + await expect(server.handleRequest('session/resume', { sessionId })).rejects.toThrow('non-empty sessionId') + } + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('not initialized') + }) +}) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 0d2460d5ed..6362aaa35f 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -12,8 +12,8 @@ en: 9a069c1a7cbb0779 zh: 7f625a802cd1e9ea /deepseek-harness-python-sdk/customize-plugins: - en: d32ee602f121e6af - zh: fdea4487688e5774 + en: a6f2fcfa7ba8a050 + zh: 60fdca6dc0594c79 /deepseek-harness-python-sdk/results-and-notifications: en: 173d2dc1ce63fe2d zh: 5354e18c59f20878 diff --git a/python/sdk/README.md b/python/sdk/README.md index 78226d3d2e..0609a1ca18 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -79,6 +79,14 @@ 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`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index c288da9be1..866813b9e6 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -79,6 +79,14 @@ 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`。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index d82f7f90f2..29ff23627b 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -121,6 +121,15 @@ class DeepSeekHarness: self.start() return Session(self, session_id or f"session-{uuid.uuid4().hex}") + 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. + """ + self.start() + restored_id = self._client.resume_session(session_id) + return Session(self, restored_id) + def run( self, input: str | list[JsonObject], diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index ed1c4ccac8..3cb8be8238 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -14,7 +14,7 @@ from typing import Callable, TypeAlias, TypeVar from pydantic import BaseModel -from .errors import JsonRpcError, TransportClosedError +from .errors import JsonRpcError, SdkProtocolError, TransportClosedError from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification ModelT = TypeVar("ModelT", bound=BaseModel) @@ -188,6 +188,17 @@ class HarnessClient: ) return response.messageId + def resume_session(self, session_id: str) -> str: + """Restore a persisted session without sending a prompt; propagate restoration failures.""" + response = self.request( + "session/resume", + {"sessionId": session_id}, + response_model=_SessionResumeResponse, + ) + 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.""" response = self.request( @@ -599,6 +610,10 @@ class _SessionPromptResponse(BaseModel): messageId: str +class _SessionResumeResponse(BaseModel): + sessionId: str + + class _WorkingDirectoryResponse(BaseModel): cwd: str diff --git a/python/sdk/tests/test_resume.py b/python/sdk/tests/test_resume.py new file mode 100644 index 0000000000..78b1475c56 --- /dev/null +++ b/python/sdk/tests/test_resume.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, SdkProtocolError +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: + script = tmp_path / "runtime.py" + requests = tmp_path / "requests.jsonl" + script.write_text( + """ +import json +import os +import sys + +for line in sys.stdin: + frame = json.loads(line) + with open(os.environ["REQUESTS"], "a") as log: + log.write(line) + method = frame["method"] + response = {"jsonrpc": "2.0", "id": frame["id"]} + if method == "initialize": + response["result"] = {"serverInfo": {"name": "fake-runtime"}} + elif method == "session/resume": + outcome = os.environ["OUTCOME"] + if outcome == "missing": + response["error"] = {"code": -32603, "message": "session not found"} + else: + response["result"] = {"sessionId": "saved" if outcome == "restored" else "wrong"} + else: + response["result"] = {} + print(json.dumps(response), flush=True) + if method == "shutdown": + break +""", + encoding="utf-8", + ) + with DeepSeekHarness( + _launch_args=(sys.executable, str(script)), + env={"OUTCOME": outcome, "REQUESTS": str(requests)}, + ) as harness: + if outcome == "restored": + 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"): + 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"] + assert frames[1]["params"] == {"sessionId": "saved"} diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index f5028a8476..66ae228446 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -62,6 +62,7 @@ import { type SdkPromptContentBlock, } from '@deepseek-ai/dsh-sdk-client' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' const corpusRoot = fileURLToPath(new URL('../', import.meta.url)) @@ -100,6 +101,8 @@ 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. */ @@ -126,6 +129,7 @@ interface SdkAssertions { } const SDK_ASSERTIONS: Readonly> = { + 'session-resume': { restartBetweenTurns: true }, 'session-title-stability': { waitForProviderTitle: true }, 'dynamic-tool-updates': { expectedFinalResponse: 'DONE', @@ -598,6 +602,16 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ignoredRootEntries: RUNTIME_WORKSPACE_ENTRIES, }) const [parentFixture, ...childFixtures] = replayFixtures + const actions = turnActions(primaryFixture) + const resumeReplayFile = assertions.restartBetweenTurns === true && !recording + ? join(patchRoot, 'resume-replay.json') : undefined + const prepareTurnReplay = async (turn: number): Promise => { + if (resumeReplayFile === undefined || parentFixture === undefined) return + const events = parseSessionLog(await readFile(parentFixture, 'utf8')) + .filter(event => 'turn' in event.data && event.data.turn === turn) + await writeFile(resumeReplayFile, JSON.stringify(deriveReplayScript(events))) + } + if (actions[0] !== undefined) await prepareTurnReplay(actions[0].turn) const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, DSH_SNAPSHOT: mode, @@ -616,9 +630,10 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ...scenario.manifest.environment, ...assertions.environment, ...childEnvironment, + ...resumeReplayFile === undefined ? {} : { DSH_SNAPSHOT_OVERRIDE: resumeReplayFile }, } - const harness = new DeepSeekHarness({ + const createHarness = (): DeepSeekHarness => new DeepSeekHarness({ profile: 'sdk', patches, dshHome, @@ -629,6 +644,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ provider: route.provider, model: route.model, }) + let harness = createHarness() try { const notifications: HarnessNotification[] = [] const observedMethods = new Set() @@ -636,7 +652,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ const sessionId = 'fixture-root-session' const liveSessions: (string | undefined)[] = [sessionId] await harness.start() - const subscription = harness.client.subscribeSessionTree(sessionId) + let subscription = harness.client.subscribeSessionTree(sessionId) const titleSubscription = assertions.waitForProviderTitle === true ? harness.client.subscribeSessionTree(sessionId) : undefined @@ -653,8 +669,8 @@ async function runScenario(scenario: CorpusScenario): Promise<{ liveSessions.push(child) } try { - const session = harness.session(sessionId) - for (const action of turnActions(primaryFixture)) { + let session = harness.session(sessionId) + for (const action of actions) { if (action.content === undefined) { await waitForRootEvent( subscription, @@ -664,6 +680,17 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ) continue } + if (assertions.restartBetweenTurns === true && results.length > 0) { + subscription.close() + await harness.close() + await prepareTurnReplay(action.turn) + harness = createHarness() + await harness.start() + subscription = harness.client.subscribeSessionTree(sessionId) + await expect(harness.resumeSession('missing-session')).rejects.toThrow() + session = await harness.resumeSession(sessionId) + expect(session.id).toBe(sessionId) + } const result = await session.run(materializeInput(action.content, scenario, cwd, liveSessions), { onNotification: (notification) => { notifications.push(notification) diff --git a/snapshots/sdk/session-resume/session.v4.jsonl b/snapshots/sdk/session-resume/session.v4.jsonl new file mode 100644 index 0000000000..1c8d6cbf1a --- /dev/null +++ b/snapshots/sdk/session-resume/session.v4.jsonl @@ -0,0 +1,27 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783352113760,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly the word:","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18},"stream":[{"type":"chunk","time":1791607745599,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1791607745600,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]},{"type":"chunk","time":1791607745601,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1791607745601,"index":1,"dt":[],"texts":["ONE"]},{"type":"chunk","time":1791607745601,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}},{"type":"chunk","time":1791607745601,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}},{"type":"chunk","time":1791607745601,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}},{"type":"chunk","time":1791607745601,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","data":{}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18},"stream":[{"type":"chunk","time":1791607752436,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1791607752436,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","T","WO","\""," and"," no"," tools","."]},{"type":"chunk","time":1791607752437,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1791607752437,"index":1,"dt":[0],"texts":["T","WO"]},{"type":"chunk","time":1791607752437,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}},{"type":"chunk","time":1791607752437,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}},{"type":"chunk","time":1791607752437,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}},{"type":"chunk","time":1791607752437,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/session-resume/snapshot.yml b/snapshots/sdk/session-resume/snapshot.yml new file mode 100644 index 0000000000..50d0ea545d --- /dev/null +++ b/snapshots/sdk/session-resume/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: session-resume +profile: sdk +composition: default +recording: authored +header: + class: default From 4f2fc8935ae923456036a0355cf86bc75cb30c89 Mon Sep 17 00:00:00 2001 From: fz Date: Sat, 10 Oct 2026 13:17:43 +0800 Subject: [PATCH 2/3] test(sdk): align replay initialization with request budget --- snapshots/sdk/sdk.snapshot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 66ae228446..7dfa404063 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -639,6 +639,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ dshHome, processCwd: cwd, env, + initializeTimeoutMs: 110_000, requestTimeoutMs: 110_000, cwd, provider: route.provider, From b1d5671038318c9824e8f5f76903d9dcf5dede38 Mon Sep 17 00:00:00 2001 From: fz Date: Sat, 10 Oct 2026 15:21:40 +0800 Subject: [PATCH 3/3] refactor(sdk): simplify resume acknowledgement and verify Python restoration --- packages/sdk/client/README.i18n.yaml | 4 +- packages/sdk/client/README.md | 2 +- packages/sdk/client/README.zh.md | 2 +- packages/sdk/client/src/api.ts | 4 +- packages/sdk/client/src/client.ts | 10 +--- packages/sdk/client/tests/fake-runtime.ts | 2 +- packages/sdk/client/tests/sdk-client.spec.ts | 4 +- packages/sdk/protocol/README.i18n.yaml | 4 +- packages/sdk/protocol/README.md | 4 +- packages/sdk/protocol/README.zh.md | 4 +- packages/sdk/protocol/src/index.ts | 1 - packages/sdk/protocol/src/types.ts | 8 +-- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 2 +- packages/sdk/server/src/server.ts | 7 +-- packages/sdk/server/tests/resume.spec.ts | 14 +++-- python/sdk/README.i18n.yaml | 8 +-- python/sdk/README.md | 16 +++--- python/sdk/README.zh.md | 16 +++--- python/sdk/src/deepseek_harness/api.py | 6 +- python/sdk/src/deepseek_harness/client.py | 17 ++---- python/sdk/tests/test_resume.py | 13 ++--- scripts/smoke-python-runtime.py | 59 +++++++++++++++++--- snapshots/sdk/sdk.snapshot.ts | 7 +-- 25 files changed, 122 insertions(+), 98 deletions(-) diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index 275bb84fc6..db75bc1878 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -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 diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 05b5de7825..2b64ba7417 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## 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. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index e74f0e1119..2dd38f0251 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -25,7 +25,7 @@ kind: "package-library" ## 使用本包 -`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` 作为首个参数。 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 010e532755..33436b0074 100644 --- a/packages/sdk/client/src/api.ts +++ b/packages/sdk/client/src/api.ts @@ -112,8 +112,8 @@ export class DeepSeekHarness implements AsyncDisposable { */ async resumeSession(sessionId: string): Promise { 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) } /** diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index 93b90f379e..632e06d872 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -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 { - 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 { + await this.request('session/resume', { sessionId }) } /** diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index 14f1f9cbbc..bbdec4a4e6 100644 --- a/packages/sdk/client/tests/fake-runtime.ts +++ b/packages/sdk/client/tests/fake-runtime.ts @@ -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': diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index e7ea2a733c..7618df7218 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -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 () => { diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index c313108790..aa6e00701a 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -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 diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 5fee771642..2b9ff7eacf 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -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 diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index adac676e09..ebafcdccb0 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -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 }`,恢复该会话后返回 `{}`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 ### 载荷语义 diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts index d62985dea0..698ecb6360 100644 --- a/packages/sdk/protocol/src/index.ts +++ b/packages/sdk/protocol/src/index.ts @@ -23,7 +23,6 @@ export type { SessionPromptParams, SessionPromptResult, SessionResumeParams, - SessionResumeResult, SessionWaitParams, SessionWorkingDirectoryParams, SessionWorkingDirectorySetParams, diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index 265bfee98b..25891197e2 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -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 } 'session/wait': { params: SessionWaitParams; result: Record } 'session/working-directory/get': { params: SessionWorkingDirectoryParams; result: SessionWorkingDirectoryResult } 'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index c1fd3aea09..2f62e76afe 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: aeb6fcf86da385f6 - zh: 7d294651bd621ee7 + en: e136ff3a0b42cc22 + zh: f6c4df5bbafa335f /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 9ed095df3c..bc78c02cdb 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## 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. diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 077e2f167f..772ae98e6f 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -`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,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index fe76002883..726f430f15 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -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 { + async resume(params: SessionResumeParams): Promise> { 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 {} } /** diff --git a/packages/sdk/server/tests/resume.spec.ts b/packages/sdk/server/tests/resume.spec.ts index e0a7df70d9..e60a648a18 100644 --- a/packages/sdk/server/tests/resume.spec.ts +++ b/packages/sdk/server/tests/resume.spec.ts @@ -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') diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 6362aaa35f..4f3ea7ad34 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: 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 diff --git a/python/sdk/README.md b/python/sdk/README.md index 0609a1ca18..c92b061717 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -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`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 866813b9e6..3e58512d87 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -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`。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 29ff23627b..0bc5d7211d 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -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, diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 3cb8be8238..0428e56b46 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -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 diff --git a/python/sdk/tests/test_resume.py b/python/sdk/tests/test_resume.py index 78b1475c56..85c8cd4b7a 100644 --- a/python/sdk/tests/test_resume.py +++ b/python/sdk/tests/test_resume.py @@ -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"] diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 417edb67d2..0ae9d1e268 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -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.""" diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 7dfa404063..8bb5fa5505 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -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> = { - '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 => { 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)