diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index b949664d35..541ad6c737 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -9,8 +9,8 @@ en: b786affe03adf63c zh: aacb5d4af10ca477 /deepseek-ai-dsh-sdk-client/summary: - en: 05b18f61ad4c2f54 - zh: 19f88d7bcd2cca0f + en: cdcd88191ab0e186 + zh: 9af8bf55cdf3e663 /deepseek-ai-dsh-sdk-client/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -20,9 +20,12 @@ /deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness: en: 01c58dddd7e62de2 zh: 6dbd46e244f3f135 +/deepseek-ai-dsh-sdk-client/use-this-package/sending-input-and-receiving-responses: + en: a93ae66b4b36f085 + zh: 58e62ffdd73c5e36 /deepseek-ai-dsh-sdk-client/use-this-package/lower-level-control-with-harnessclient: - en: 2eb13706964444cb - zh: a2640ec27b83113f + en: d5abaacd885c2534 + zh: 3ce12e3f9cc1b960 /deepseek-ai-dsh-sdk-client/use-this-package/native-user-questions: en: ea40f378444c8153 zh: 96e29c99aa5c15f0 @@ -33,8 +36,8 @@ en: 6632c06d819233cc zh: 207d55d8217ec6b1 /deepseek-ai-dsh-sdk-client/understand-the-implementation/source-map: - en: a3c7978a80f5aba3 - zh: fdc4dfdafd2eccfa + en: f6386d0b3e0ed379 + zh: e485893aafd97432 /deepseek-ai-dsh-sdk-client/understand-the-implementation/owned-activity-flow: en: 94a42babac5f1c7d zh: df9e42ed02f7d002 @@ -51,8 +54,8 @@ en: 7abe0be104a791e7 zh: 445311f78b1069ca /deepseek-ai-dsh-sdk-client/known-limitations-and-deferred-work: - en: 60100c38c111a0b0 - zh: 2085486bd36f5e48 + en: 83af5fc89e204323 + zh: 10b4455f6e6ed55d /deepseek-ai-dsh-sdk-client/known-limitations-and-deferred-work/dev-note: en: 6177a56f053936a4 zh: 0f4b20228c2d15da diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 7d7048c6fc..1d41840dab 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-sdk-client` lets TypeScript programs start and drive a complete DeepSeek Harness runtime over stdio JSON-RPC. Use `DeepSeekHarness` to open sessions, send text or image prompts, collect event and notification streams, and obtain the last committed assistant response when the runtime becomes idle; use `HarnessClient` for direct protocol requests and subscriptions. Callers may provide `dshBin`; otherwise the client resolves the same-version `@deepseek-ai/dsh` executable. The client owns the subprocess across runs, exposes typed transport and protocol failures, and reaps it on `close()` or `await using`. It is suitable when the caller can choose the runtime profile and launch settings. +`dsh-sdk-client` lets TypeScript programs drive a complete DeepSeek Harness runtime over stdio JSON-RPC. Use `query()` and `receiveResponse()` to send text or slash input separately from receiving notifications and its result; use `run()` to collect a model activity interval. `HarnessClient` provides direct protocol access. Callers may provide `dshBin`; otherwise the client resolves the same-version `@deepseek-ai/dsh` executable. The client owns the subprocess, reports typed failures, and reaps it on `close()` or `await using`. ## Table of Contents @@ -53,8 +53,30 @@ console.log(result.finalResponse) The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; call `close()` (or use `await using`) so the child is always reaped. `start()` memoizes the bounded `initialize` handshake, which carries the workspace cwd, provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The server validates that exact route before it accepts prompts; an omitted effort preserves the model's default. `initializeTimeoutMs` defaults to 10 seconds, and its diagnostic names the selected profile with the retained stderr tail. `run(input, { sessionId?, onNotification? })` accepts text or `SdkPromptContentBlock[]`; an inline raster block carries canonical base64 plus `mimeType` and becomes a durable attachment inside the runtime. The call owns one activity interval: it queues the prompt, waits until its message id appears in a durable inbox receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`, where `finalResponse` is the last committed root-session assistant text in that interval — not a response causally assigned to the prompt, because steering, injected context, and other queued work may contribute before idle. `session(id?)` opens a named or fresh session handle. When a failed handshake is cleaned up successfully, the instance installs a fresh client so a later call retries with a new process until terminal `close()`; if initialization and cleanup both fail, `start()` returns an ordered `AggregateError` and retains the failed client instead of spawning beside a process whose exit is unproved. `maxTokens` caps each root-agent request output and is inherited by in-process descendants; compaction plugins own their separate summary limits. +### Sending input and receiving responses + +`await session.query(text, { delivery: 'queue' })` starts the runtime if needed, sends text, and returns without awaiting runtime routing or execution. Delivery defaults to `queue`; `steer` targets the next available step for ordinary input. The runtime intercepts bare `/compact`; other text, including `/plan` and `/compact extra`, retains its ordinary-prompt behavior. `run("/compact")` also remains an ordinary prompt. Query is text-only; use `run()` for content-block prompts. + +```ts +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' + +await using harness = new DeepSeekHarness({ profile: 'sdk' }) +const session = harness.session() +await session.query('/compact') +for await (const message of session.receiveResponse()) { + if ('commandId' in message) console.log(message.result) + else if ('finalResponse' in message) console.log(message.finalResponse) +} +``` + +Each `receiveResponse()` call takes the oldest pending query and returns an independent `AsyncIterableIterator`. It yields buffered notifications, then a final `CommandExecution` for a command or `RunResult` for a prompt. Prompt observation starts at its inbox receipt and ends at the next root idle; command observation needs neither a turn nor idle. Routing/protocol/transport failures surface during reception, while command business refusals remain `result.kind === "error"`. A completed command retains `commandId` and any summary `sourceEventSeq`, not an assistant reply. + +Receivers may run concurrently, so an outstanding model response does not prevent sending and receiving `/compact` with its native busy result. Subscriptions are installed before sending and released before the final result is yielded. Close an unused iterator with `return()`, even before its first `next()`, to discard its buffered events and abandon its local RPC wait. This sends no runtime cancellation and authorizes no automatic retry. Continue servicing the Session until its work finishes or close the harness; command completion does not end independent model activity. + ### Lower-level control with HarnessClient +`client.query(sessionId, text, delivery, signal?)` waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` is the advanced strict-command entry and currently supports `/compact`; it waits for the native handler, including auxiliary summary work. Subscribe before these calls when notifications are needed. An optional fourth `request()` argument supplies an `AbortSignal` that abandons only the local wait; runtime work can continue after timeout or local abandonment. + `HarnessClient` is the protocol client under the run API: explicit `start()`, `initialize()`, `prompt()`, `request()`, and `close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it and never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Task-owning consumers can subscribe before `prompt()`, then call `request('session/wait', { sessionId })` and drain queued notifications with `tryNext()`. This waits for managed descendants and their root summary turns; `HarnessSession.run()` retains its next-idle interval. `validatedSessionEvent()` validates the event envelope and the assistant/turn fields consumed by SDK readers before they enter an output fold. @@ -86,10 +108,10 @@ The client is two layers over one wire: `DeepSeekHarness` (owned runs) over `Har | File | Role | |---|---| -| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`: owned runs, receipt-to-idle collection, `finalResponse` | +| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`: pending queries, independent receivers, owned runs and `finalResponse` | | [`src/client.ts`](src/client.ts) | `HarnessClient`: spawn, handshake, requests, subscription fan-out, typed errors | | [`src/dispose.ts`](src/dispose.ts) | Private teardown ladder: stdin EOF → SIGTERM → SIGKILL to actual exit | -| [`src/types.ts`](src/types.ts) | Launch and timeout options, notification shapes, `RunResult` | +| [`src/types.ts`](src/types.ts) | Launch and timeout options, notifications, `QueryMessage` and `RunResult` | | [`src/index.ts`](src/index.ts) | Consumer interface: the two client layers and caller-facing types | ### Owned activity flow @@ -134,8 +156,8 @@ None in the client process. Profile, patch, provider, model, and history choices These limits define when the client is a poor fit or needs special care. They are current package constraints, not a comparison with other SDK clients or a task backlog. - **No bundled-runtime resolution** — the client resolves the same-version `@deepseek-ai/dsh` package (or a caller-provided `dshBin`); packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists. -- **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the [protocol limitations](../protocol/README.md#known-limitations-and-deferred-work)). -- **No per-prompt result** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime. +- **No runtime cancellation request** — closing a receiver abandons local observation, not execution. Stopping runtime work requires closing the harness (see the [protocol limitations](../protocol/README.md#known-limitations-and-deferred-work)). +- **No exclusive model response** — prompt results cover receipt-to-idle activity and can include steering or other queued work. Query receivers share the Session's actual event stream; a command result does not own a later turn. - **Callbacks must release their own resources** — the SDK delivers cancellation through `AbortSignal`; application work that ignores it may continue after the SDK stops awaiting its answer. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index b55b0f399b..4aac2556f6 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-sdk-client` 让 TypeScript 程序通过 stdio JSON-RPC 启动并驱动完整的 DeepSeek Harness 运行时。使用 `DeepSeekHarness` 可打开会话、发送文本或图像提示词、收集事件与通知流,并在运行时进入 idle 后取得最后提交的助手响应;使用 `HarnessClient` 可直接发送协议请求和订阅通知。调用方可以提供 `dshBin`;否则客户端解析同版本的 `@deepseek-ai/dsh` 可执行文件。客户端跨多次运行持有子进程,公开类型化的传输与协议错误,并在 `close()` 或 `await using` 时回收进程。它适用于调用方能够选择运行时 profile 和启动设置的场景。 +`dsh-sdk-client` 让 TypeScript 程序通过 stdio JSON-RPC 驱动完整的 DeepSeek Harness 运行时。使用 `query()` 与 `receiveResponse()` 可分开发送文本或 slash 输入与接收通知及结果;使用 `run()` 可收集模型活动区间。`HarnessClient` 提供直接协议访问。调用方可以提供 `dshBin`;否则客户端解析同版本的 `@deepseek-ai/dsh` 可执行文件。客户端持有子进程,报告类型化错误,并在 `close()` 或 `await using` 时回收进程。 ## 目录 @@ -53,8 +53,30 @@ console.log(result.finalResponse) 子进程在首次使用时惰性启动,并在多次 `run()` 调用之间持续归实例所有;请调用 `close()`(或使用 `await using`),子进程才总能被回收。`start()` 会记忆化有界的 `initialize` 握手,其中包含工作区 cwd、提供方/模型路由、可选且由适配器持有的 `reasoningEffort`,以及可选的正整数 `maxTokens` 输出上限。服务器会在接受提示词前校验该确切路由;省略推理强度时保留模型自身的默认值。`initializeTimeoutMs` 默认 10 秒,诊断会写明所选 profile 并附带保留的 stderr 尾部。`run(input, { sessionId?, onNotification? })` 接受文本或 `SdkPromptContentBlock[]`;内联栅格图像块携带规范 base64 与 `mimeType`,并在运行时内变成持久附件。该调用拥有一个活动区间:它将提示词排入队列,等待其消息 id 出现在持久入队回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`,其中 `finalResponse` 是该区间内根会话最后提交的助手文本——并非因果上归属于该提示词的响应,因为 steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`session(id?)` 打开具名或全新的会话句柄。握手失败且清理成功时,实例会换入全新客户端,使后续调用用新进程重试,直到终结性的 `close()`;如果初始化和清理均失败,`start()` 会返回保留两个原因的有序 `AggregateError`,并继续保留失败的客户端,避免在原进程退出尚未得到证明时启动另一个进程。`maxTokens` 限制每个根 agent 请求的输出量,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。 +### 发送输入与接收响应 + +`await session.query(text, { delivery: 'queue' })` 在需要时启动运行时、发送文本,然后返回,不等待运行时的路由或执行结果。delivery 默认为 `queue`;对普通输入,`steer` 指向下一个可用步骤。运行时只接管裸 `/compact`;包括 `/plan` 和 `/compact extra` 在内的其他文本保留普通提示词行为。`run("/compact")` 也仍是普通提示词。query 只接受文本,内容块提示词请使用 `run()`。 + +```ts +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' + +await using harness = new DeepSeekHarness({ profile: 'sdk' }) +const session = harness.session() +await session.query('/compact') +for await (const message of session.receiveResponse()) { + if ('commandId' in message) console.log(message.result) + else if ('finalResponse' in message) console.log(message.finalResponse) +} +``` + +每次 `receiveResponse()` 调用取出最早待接收的 query,并返回独立的 `AsyncIterableIterator`。它先产出缓冲通知,再产出最终结果:命令返回 `CommandExecution`,普通提示词返回 `RunResult`。提示词观察从其入队回执开始,到根 agent 下一次 idle 结束;命令观察不需要 turn 或 idle。路由、协议及传输错误在接收时报告,命令业务拒绝仍为 `result.kind === "error"`。已完成的命令保留 `commandId` 及可选摘要 `sourceEventSeq`,而不是助手回复。 + +多个接收器可以并行运行,因此等待模型响应时仍可发送并接收 `/compact` 的原生 busy 结果。订阅在发送前建立,在最终结果产出前释放。对不再使用的迭代器调用 `return()`,即使尚未调用第一次 `next()`,也会丢弃缓冲事件并放弃本地 RPC 等待。该操作不发送运行时取消,也不允许自动重试。请持续服务 Session,直到其工作结束,或关闭 harness;命令完成不代表独立模型活动结束。 + ### 用 HarnessClient 做低层控制 +`client.query(sessionId, text, delivery, signal?)` 等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 是高级用法的严格命令入口,当前支持 `/compact`;它等待原生 handler,包括辅助摘要工作。需要通知时,应在调用前订阅。`request()` 的可选第四个参数接受 `AbortSignal`,仅放弃本地等待;超时或本地放弃后,运行时工作仍可继续。 + `HarnessClient` 是运行 API 之下的协议客户端:显式 `start()`、`initialize()`、`prompt()`、`request()` 与 `close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 id,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。 拥有完整任务的消费者可以先订阅,再调用 `prompt()`,随后调用 `request('session/wait', { sessionId })`,并通过 `tryNext()` 取完已排队的通知。这会等待受管理后代及其触发的根 Agent 摘要轮次;`HarnessSession.run()` 仍以首次空闲作为活动区间终点。`validatedSessionEvent()` 在事件进入输出折叠前,校验事件封装及 SDK 读取方使用的 assistant/turn 字段。 @@ -86,10 +108,10 @@ console.log(result.finalResponse) | 文件 | 职责 | |---|---| -| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`:自有运行、从回执到 idle 的收集、`finalResponse` | +| [`src/api.ts`](src/api.ts) | `DeepSeekHarness` + `HarnessSession`:待接收 query、独立接收器、自有运行与 `finalResponse` | | [`src/client.ts`](src/client.ts) | `HarnessClient`:spawn、握手、请求、订阅扇出、类型化错误 | | [`src/dispose.ts`](src/dispose.ts) | 私有关闭阶梯:stdin EOF → SIGTERM → SIGKILL 直到真正退出 | -| [`src/types.ts`](src/types.ts) | 启动与超时选项、通知结构、`RunResult` | +| [`src/types.ts`](src/types.ts) | 启动与超时选项、通知、`QueryMessage` 与 `RunResult` | | [`src/index.ts`](src/index.ts) | 消费方接口:两层客户端与面向调用方的类型 | ### 自有活动流程 @@ -134,8 +156,8 @@ console.log(result.finalResponse) 这些限制说明本客户端何时不合适或需要特别注意。它们是当前包约束,不是与其他 SDK 客户端的对比或任务积压。 - **无捆绑运行时解析**——客户端解析同版本 `@deepseek-ai/dsh` 包(或调用方提供的 `dshBin`);打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。 -- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见[协议限制](../protocol/README.zh.md#known-limitations-and-deferred-work))。 -- **没有逐提示词结果**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执到 idle 的收集,放弃该过程意味着关闭运行时。 +- **无运行时取消请求**——关闭接收器只放弃本地观察,不停止执行。停止运行时工作需要关闭 harness(见[协议限制](../protocol/README.zh.md#known-limitations-and-deferred-work))。 +- **无独占模型响应**——提示词结果涵盖从回执到 idle 的活动,可能包含 steering 或其他排队工作。query 接收器共享 Session 的实际事件流;命令结果不独占后续 turn。 - **回调必须释放自己的资源**——SDK 通过 `AbortSignal` 发出取消;忽略它的应用工作可能在 SDK 停止等待答案后继续运行。 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 85e61a77ac..b99fca3aee 100644 --- a/packages/sdk/client/src/api.ts +++ b/packages/sdk/client/src/api.ts @@ -1,7 +1,7 @@ /** - * High-level run API over {@link HarnessClient}: `DeepSeekHarness` owns one - * runtime subprocess across many sessions; `HarnessSession.run` sends a - * prompt and settles when the whole agent next becomes idle. + * High-level input and run APIs over {@link HarnessClient}. Sessions can send + * input independently of receiving its notifications and result, or collect + * a prompt's receipt-to-idle activity through `run()`. * * @module @deepseek-ai/dsh-sdk-client/api */ @@ -9,9 +9,11 @@ import { randomUUID } from 'node:crypto' import { resolve } from 'node:path' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionQueryResult } from '@deepseek-ai/dsh-sdk-protocol' import { createProcessHarnessClient, HarnessClient, isRecord, SdkProtocolError } from './client.ts' +import type { NotificationSubscription } from './client.ts' import type { RuntimeProcessOptions } from './launch.ts' -import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, RunResult, SdkPromptContentBlock } from './types.ts' +import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, QueryMessage, RunResult, SdkPromptContentBlock } from './types.ts' /** * Reusable SDK for running DeepSeek Harness agent turns in a runtime @@ -171,16 +173,134 @@ export interface RunOptions { onNotification?: (notification: HarnessNotification) => void } +/** Ordinary prompt delivery for one unified input; command admission remains runtime-owned. */ +export interface QueryOptions { + /** Queue a new turn by default, or steer the next available step. */ + delivery?: 'queue' | 'steer' +} + +type QueryOutcome = + | { readonly kind: 'result'; readonly result: SessionQueryResult } + | { readonly kind: 'error'; readonly error: unknown } + +interface PendingQuery { + readonly outcome: Promise + readonly subscription: NotificationSubscription + readonly abandon: AbortController + readonly commandNotifications: HarnessNotification[] +} + /** * One SDK session: a stable id plus owned activity intervals. */ export class HarnessSession { + private readonly pendingQueries: PendingQuery[] = [] + /** * @param harness - the owning harness (supplies the client and handshake). * @param id - the wire session id this handle runs on. */ constructor(readonly harness: DeepSeekHarness, readonly id: string) {} + /** + * Send text for runtime prompt-or-command routing without waiting for its result. + * @param text - complete input text; the runtime interprets slash input. + * @param options - ordinary prompt delivery, defaulting to a new queued turn. + * @returns after initialization and sending; routing failures surface from `receiveResponse()`. + */ + async query(text: string, options: QueryOptions = {}): Promise { + await this.harness.start() + const client = this.harness.client + const subscription = client.subscribeSessionTree(this.id) + const abandon = new AbortController() + const commandNotifications: HarnessNotification[] = [] + const outcome = client.query(this.id, text, options.delivery ?? 'queue', abandon.signal).then( + (result) => { + if (result.kind === 'command') { + for (let notification = subscription.tryNext(); notification !== undefined; notification = subscription.tryNext()) { + commandNotifications.push(notification) + const event = notification.params.event + if (notification.method === 'session.event' && notification.params.sessionId === this.id + && isRecord(event) && event.type === 'command/done' && isRecord(event.data) + && event.data.commandId === result.execution.commandId) break + } + subscription.close() + } + return { kind: 'result', result } + }, + ).catch((error: unknown): QueryOutcome => { + subscription.close() + return { kind: 'error', error } + }) + this.pendingQueries.push({ outcome, subscription, abandon, commandNotifications }) + } + + /** + * Observe the oldest query not yet assigned to a receiver. + * @returns an iterator of notifications followed by `RunResult` or `CommandExecution`. + * Its resources close before the final result is yielded. Returning early abandons + * only local observation, including before the first `next()`; runtime work continues. + * @throws when no query awaits reception, or when its request, protocol, or transport fails. + */ + receiveResponse(): AsyncIterableIterator { + const pending = this.pendingQueries.shift() + if (pending === undefined) throw new Error('No query is waiting for a response') + const isClosed = (): boolean => pending.abandon.signal.aborted + const close = (): void => { + if (isClosed()) return + pending.abandon.abort(new Error('Query observation closed; runtime work was not cancelled')) + pending.subscription.close() + pending.commandNotifications.length = 0 + } + const sessionId = this.id + const iterator = (async function* (): AsyncGenerator { + try { + const outcome = await pending.outcome + if (isClosed()) return + if (outcome.kind === 'error') throw outcome.error + if (outcome.result.kind === 'command') { + for (const notification of pending.commandNotifications) yield notification + close() + yield outcome.result.execution + return + } + const events: SessionEvent[] = [] + const notifications: HarnessNotification[] = [] + let received = false + while (!isClosed()) { + const notification = await pending.subscription.next() + if (!received) { + if (notification.method !== 'session.event' || notification.params.sessionId !== sessionId + || !isInboxReceipt(notification.params.event, outcome.result.messageId)) continue + received = true + } + if (notification.method === 'session.event' && notification.params.sessionId === sessionId) { + events.push(validatedSessionEvent(notification.params.event)) + } + notifications.push(notification) + yield notification + if (notification.method === 'session.status' && notification.params.sessionId === sessionId + && notification.params.status === 'idle') break + } + if (isClosed()) return + close() + yield { sessionId, finalResponse: finalResponse(events), events, notifications } + } catch (error: unknown) { + if (!isClosed()) throw error + } finally { + close() + } + })() + return { + [Symbol.asyncIterator]() { return this }, + next: () => iterator.next(), + return: () => { + close() + return iterator.return(undefined) + }, + } + } + /** * Read the effective directory and recover a missing directory to the Session origin. * @returns the absolute directory used by new independent operations. diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index 4bc8140c02..01c9ec44d8 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -16,12 +16,17 @@ import { spawn, type ChildProcess } from 'node:child_process' import { JsonRpcLineTransport, JsonRpcResponseError, + type CommandExecution, type InitializeParams, type InitializeResult, type SessionPromptParams, + type SessionCommandParams, + type SessionQueryParams, + type SessionQueryResult, type SdkPromptContentBlock, type SdkUserQuestionRequest, } from '@deepseek-ai/dsh-sdk-protocol' +import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { disposeRuntimeProcess } from './dispose.ts' import { resolveDshLaunch, type RuntimeProcessOptions } from './launch.ts' import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts' @@ -356,6 +361,46 @@ export class HarnessClient { return result.messageId } + /** + * Execute a strict command without waiting for an inbox receipt or Agent idle. + * @param sessionId - target Session; an unknown id creates it. + * @param line - complete command line; currently only `/compact` is supported. + * @returns the native command result, including a business error when the handler refuses it. + * @throws on protocol rejection, malformed response, timeout or transport loss; never retries. + */ + async executeCommand(sessionId: string, line: string): Promise { + const params: SessionCommandParams = { sessionId: SessionId(sessionId), line } + const value = await this.request('session/command', { ...params }) + return commandExecution(value, 'session/command') + } + + /** + * Send text for runtime prompt-or-command routing and await its receipt. + * @param sessionId - target Session; an unknown id creates it. + * @param text - complete input text, without client-side command interpretation. + * @param delivery - ordinary prompt delivery; commands retain their own admission rules. + * @param signal - abandons this client's wait without cancelling runtime work. + * @returns the prompt receipt or native command execution. + */ + async query( + sessionId: string, + text: string, + delivery: SessionQueryParams['delivery'], + signal?: AbortSignal, + ): Promise { + const params: SessionQueryParams = { sessionId: SessionId(sessionId), text, delivery } + const value = await this.request('session/query', params, undefined, signal) + if (isRecord(value)) { + if (value.kind === 'prompt' && typeof value.messageId === 'string' && value.messageId.length > 0) { + return { kind: 'prompt', messageId: value.messageId } + } + if (value.kind === 'command') { + return { kind: 'command', execution: commandExecution(value.execution, 'session/query') } + } + } + throw new SdkProtocolError(`session/query returned an invalid routing result: ${JSON.stringify(value)}`) + } + /** * Restore persisted history without sending a prompt; failures propagate without creating a session. * @param sessionId - persisted identity in this runtime's configured persistence store. @@ -397,11 +442,13 @@ export class HarnessClient { * @param method - the wire method name. * @param params - the params object; omitted params send `{}`. * @param timeoutMs - per-call override of {@link HarnessClientOptions.requestTimeoutMs}. + * @param signal - abandon only this local request wait; no cancellation is sent to the runtime. * @returns the raw result; rejects with {@link JsonRpcResponseError} on a * protocol error response, {@link RequestTimeoutError} on timeout, and * {@link TransportClosedError} when the runtime is gone. */ - async request(method: string, params?: object, timeoutMs?: number): Promise { + async request(method: string, params?: object, timeoutMs?: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() this.start() // A dead runtime cannot answer; fail with process context instead of // writing into a destroyed pipe and hanging until the timeout. @@ -414,7 +461,7 @@ export class HarnessClient { if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running') const timeout = timeoutMs ?? this.runtime.requestTimeoutMs try { - if (timeout === undefined) return await transport.request(method, params ?? {}) + if (timeout === undefined) return await transport.request(method, params ?? {}, signal) // The abort signal makes the timeout an abandonment: the transport drops // its pending entry, so repeated bounded requests against a hung method // retain no per-call state (the server-side work still runs to close). @@ -424,12 +471,13 @@ export class HarnessClient { abandon.abort(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for ${this.runtime.description}${stderr}`)) }, timeout) try { - return await transport.request(method, params ?? {}, abandon.signal) + return await transport.request(method, params ?? {}, signal === undefined + ? abandon.signal : AbortSignal.any([signal, abandon.signal])) } finally { clearTimeout(timer) } } catch (error) { - if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError) throw error + if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError || signal?.aborted) throw error // Transport-level failures gain process context: exit code + stderr tail. await this.settleStreams() throw this.closedError(errorMessage(error)) @@ -585,6 +633,27 @@ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +/** Decode the native command result shared by strict execution and unified input. */ +function commandExecution(value: unknown, method: string): CommandExecution { + if (isRecord(value) && typeof value.commandId === 'string' && value.commandId.length > 0 && isRecord(value.result)) { + const commandId = value.commandId as CommandExecution['commandId'] + const { kind, text, sourceEventSeq } = value.result + if (kind === 'success' && (text === undefined || typeof text === 'string') + && (sourceEventSeq === undefined || (typeof sourceEventSeq === 'number' + && Number.isSafeInteger(sourceEventSeq) && sourceEventSeq >= 0))) { + return { commandId, result: { + kind, + ...text === undefined ? {} : { text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq: SessionSeq(sourceEventSeq) }, + } } + } + if (kind === 'error' && typeof text === 'string' && text.trim().length > 0) { + return { commandId, result: { kind, text } } + } + } + throw new SdkProtocolError(`${method} returned an invalid command result: ${JSON.stringify(value)}`) +} + /** The message of a thrown value (the transport only throws `Error`s; `String` covers the rest). */ function errorMessage(error: unknown): string { /* v8 ignore next -- the transport and dispose ladder reject only with Errors */ diff --git a/packages/sdk/client/src/index.ts b/packages/sdk/client/src/index.ts index eaaed46afb..5c5ed08c03 100644 --- a/packages/sdk/client/src/index.ts +++ b/packages/sdk/client/src/index.ts @@ -10,7 +10,7 @@ */ export { DeepSeekHarness, HarnessSession, validatedSessionEvent } from './api.ts' -export type { RunOptions } from './api.ts' +export type { QueryOptions, RunOptions } from './api.ts' export { HarnessClient, RequestTimeoutError, @@ -21,6 +21,7 @@ export type { NotificationSubscription } from './client.ts' export { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol' export type { ContentBlock, + CommandExecution, SdkPromptContentBlock, SdkUserQuestionAnswer, SdkUserQuestionRequest, @@ -28,5 +29,6 @@ export type { HarnessClientOptions, HarnessNotification, NotificationFilter, + QueryMessage, RunResult, } from './types.ts' diff --git a/packages/sdk/client/src/types.ts b/packages/sdk/client/src/types.ts index 3cc26b5314..0f1eb03561 100644 --- a/packages/sdk/client/src/types.ts +++ b/packages/sdk/client/src/types.ts @@ -6,7 +6,7 @@ */ import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { SdkPromptContentBlock, SdkUserQuestionAnswer, SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-protocol' +import type { CommandExecution, SdkPromptContentBlock, SdkUserQuestionAnswer, SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-protocol' import type { SessionEvent } from '@deepseek-ai/dsh-session' /** One server-to-client notification as received off the wire. */ @@ -82,7 +82,11 @@ export interface RunResult { notifications: HarnessNotification[] } +/** One query observation: notifications followed by a final activity or native command result. */ +export type QueryMessage = HarnessNotification | RunResult | CommandExecution + /** Re-exported content-block alias so SDK callers need no extra import. */ export type { ContentBlock } +export type { CommandExecution } export type { SdkPromptContentBlock } export type { SdkUserQuestionAnswer, SdkUserQuestionRequest } diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index 0ddeb7f076..a70d78e6cb 100644 --- a/packages/sdk/client/tests/fake-runtime.ts +++ b/packages/sdk/client/tests/fake-runtime.ts @@ -50,6 +50,8 @@ * - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe). * - `FAKE_APPROVAL`: send one server approval request after initialization and echo its response as a notification. * - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe). + * - `FAKE_COMMAND_RESULT`: command response JSON; omission echoes the requested line. + * - `FAKE_COMMAND_ERROR`: reject a command with a JSON-RPC error. */ import { appendFileSync, existsSync, writeFileSync } from 'node:fs' @@ -258,7 +260,7 @@ reader.on('line', (line) => { return } if (frame.method === undefined || frame.id === undefined) return - const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) } + const respond = (result: unknown): void => { write({ jsonrpc: '2.0', id: frame.id, result }) } switch (frame.method) { case 'initialize': if (typeof frame.params?.cwd === 'string') originCwd = frame.params.cwd @@ -317,6 +319,17 @@ reader.on('line', (line) => { respond({ cwd }) return } + case 'session/command': { + if (env.FAKE_COMMAND_ERROR !== undefined) { + write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: 'command unavailable' } }) + } else { + const result: unknown = env.FAKE_COMMAND_RESULT === undefined + ? { commandId: 'command-1', result: { kind: 'success', text: frame.params?.line } } + : JSON.parse(env.FAKE_COMMAND_RESULT) + respond(result) + } + return + } case 'session/prompt': { const sessionId = sessionIdOf(frame.params) const messageId = `fake-user-${seq}` diff --git a/packages/sdk/client/tests/fixtures/query.ts b/packages/sdk/client/tests/fixtures/query.ts new file mode 100644 index 0000000000..f10914d15a --- /dev/null +++ b/packages/sdk/client/tests/fixtures/query.ts @@ -0,0 +1,107 @@ +/** Protocol peer with controllable query settlement and independently serviced requests. */ +import { createInterface } from 'node:readline' + +interface Frame { + id?: string | number + method: string + params?: Record +} + +const queries: Record[] = [] +const methods: string[] = [] +const promptFinishes = new Map void>() +const commandFinishes = new Map void>() +let seq = 0 + +function write(frame: object): void { + process.stdout.write(JSON.stringify(frame) + '\n') +} + +function notify(method: string, params: object): void { + write({ jsonrpc: '2.0', method, params }) +} + +function event(sessionId: string, type: string, data: object): void { + notify('session.event', { sessionId, event: { type, seq: seq++, time: 0, data } }) +} + +createInterface({ input: process.stdin }).on('line', (line) => { + const frame = JSON.parse(line) as Frame + methods.push(frame.method) + const respond = (result: unknown): void => { write({ jsonrpc: '2.0', id: frame.id, result }) } + if (frame.method === 'initialize') { + respond({ serverInfo: { name: 'query-fixture', version: '1' } }) + } else if (frame.method === 'session/query') { + const params = frame.params ?? {} + queries.push(params) + const sessionId = String(params.sessionId) + const text = String(params.text) + if (process.env.QUERY_DISCONNECT !== undefined) { + process.exit(13) + } + if (process.env.QUERY_RESULT !== undefined) { + respond(JSON.parse(process.env.QUERY_RESULT)) + return + } + if (text === process.env.QUERY_ERROR_TEXT) { + write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: 'scripted query failure' } }) + return + } + if (text === '/compact') { + const commandId = `command-${queries.length}` + const result = promptFinishes.has(sessionId) + ? { kind: 'error', text: 'busy' } + : { kind: 'success', text: 'compacted', sourceEventSeq: 0 } + event(sessionId, 'command/run', { commandId, name: 'compact', args: '', source: { kind: 'user' } }) + const finish = (): void => { + commandFinishes.delete(sessionId) + event(sessionId, 'command/done', { commandId, ...result }) + respond({ kind: 'command', execution: { commandId, result } }) + } + if (process.env.QUERY_HOLD_COMMAND !== undefined && !promptFinishes.has(sessionId)) { + commandFinishes.set(sessionId, finish) + } else { + finish() + } + return + } + const messageId = `input-${queries.length}` + event(sessionId, 'assistant/message', { message: { content: [{ type: 'text', text: 'before receipt' }] } }) + event(sessionId, 'agent/inbox/spliced', { + target: params.delivery === 'steer' ? 'next-step' : 'next-turn', + start: 0, + inserted: [{ id: messageId, role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text }] }], + }) + notify('session.status', { sessionId, status: 'running' }) + const finish = (): void => { + promptFinishes.delete(sessionId) + if (text === 'with-child') { + notify('subagent.started', { parentSessionId: sessionId, childSessionId: 'child' }) + event('child', 'assistant/message', { message: { content: [{ type: 'text', text: 'child answer' }] } }) + event('unrelated', 'assistant/message', { message: { content: [{ type: 'text', text: 'unrelated answer' }] } }) + } + event(sessionId, 'assistant/message', { message: { content: [{ type: 'text', text: `reply:${text}` }] } }) + event(sessionId, 'turn/end', { turn: queries.length, reason: { kind: 'completed' } }) + notify('session.status', { sessionId, status: 'idle' }) + } + if (text === 'hold') { + promptFinishes.set(sessionId, finish) + respond({ kind: 'prompt', messageId }) + } else { + finish() + respond({ kind: 'prompt', messageId }) + } + } else if (frame.method === 'test/state') { + respond({ queries, methods, waitingPrompts: promptFinishes.size, waitingCommands: commandFinishes.size }) + } else if (frame.method === 'test/release') { + for (const finish of [...promptFinishes.values(), ...commandFinishes.values()]) finish() + respond({}) + } else if (frame.method === 'test/disconnect') { + process.exit(13) + } else if (frame.method === 'shutdown') { + respond({}) + setImmediate(() => process.exit(0)) + } else { + write({ jsonrpc: '2.0', id: frame.id, error: { code: -32601, message: 'unknown fixture method' } }) + } +}) diff --git a/packages/sdk/client/tests/query.spec.ts b/packages/sdk/client/tests/query.spec.ts new file mode 100644 index 0000000000..9c85e282e0 --- /dev/null +++ b/packages/sdk/client/tests/query.spec.ts @@ -0,0 +1,271 @@ +/** Unified input receipt and observation over an independently running protocol peer. */ +import { fileURLToPath } from 'node:url' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createProcessDeepSeekHarness } from '../src/api.ts' +import { JsonRpcResponseError, RequestTimeoutError, SdkProtocolError, TransportClosedError } from '../src/index.ts' +import type { QueryMessage, RunResult } from '../src/types.ts' + +function harnessWith(environment: Record = {}, requestTimeoutMs?: number) { + const harness = createProcessDeepSeekHarness({ + command: process.execPath, + args: [fileURLToPath(new URL('./fixtures/query.ts', import.meta.url))], + environment: () => ({ ...process.env, ...environment }), + description: 'query protocol fixture', + initializeTimeoutMs: 5000, + ...requestTimeoutMs === undefined ? {} : { requestTimeoutMs }, + }) + onTestFinished(() => harness.close()) + return harness +} + +async function collect(iterator: AsyncIterableIterator): Promise { + const messages: QueryMessage[] = [] + for await (const message of iterator) messages.push(message) + return messages +} + +function finalRun(messages: QueryMessage[]): RunResult { + const result = messages.at(-1) + if (result === undefined || !('finalResponse' in result)) throw new Error('query did not yield RunResult') + return result +} + +it('sends a command without waiting for its result and buffers the complete command notifications', async () => { + const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }) + const session = harness.session('main') + await session.query('/compact') + expect(await harness.client.request('test/state')).toMatchObject({ + queries: [{ sessionId: 'main', text: '/compact', delivery: 'queue' }], waitingCommands: 1, + }) + await harness.client.request('test/release') + const messages = await collect(session.receiveResponse()) + expect(messages.map(message => 'method' in message ? message.method : 'command')).toEqual([ + 'session.event', 'session.event', 'command', + ]) + expect(messages.at(-1)).toEqual({ commandId: 'command-1', result: { kind: 'success', text: 'compacted', sourceEventSeq: 0 } }) +}) + +it('receives each pending prompt in FIFO order and forwards explicit steer delivery', async () => { + const session = harnessWith().session('main') + await session.query('first') + await session.query('second', { delivery: 'steer' }) + const first = finalRun(await collect(session.receiveResponse())) + const second = finalRun(await collect(session.receiveResponse())) + expect(first.finalResponse).toBe('reply:first') + expect(second.finalResponse).toBe('reply:second') + expect(first.notifications[0]).toMatchObject({ params: { event: { type: 'agent/inbox/spliced', data: { target: 'next-turn' } } } }) + expect(second.notifications[0]).toMatchObject({ params: { event: { type: 'agent/inbox/spliced', data: { target: 'next-step' } } } }) + expect(first.events.some(event => event.type === 'assistant/message' && event.data.message.content.some(block => + block.type === 'text' && block.text === 'before receipt'))).toBe(false) + expect(() => session.receiveResponse()).toThrow('No query') +}) + +it('keeps root result events separate from discovered descendant notifications', async () => { + const session = harnessWith().session('main') + await session.query('with-child') + const messages = await collect(session.receiveResponse()) + const result = finalRun(messages) + expect(result.finalResponse).toBe('reply:with-child') + expect(result.notifications.some(notification => notification.params.sessionId === 'child')).toBe(true) + expect(result.notifications.some(notification => notification.params.sessionId === 'unrelated')).toBe(false) + expect(result.events.filter(event => event.type === 'assistant/message')).toHaveLength(1) +}) + +it('receives compact busy while an earlier receiver is still waiting for model activity', async () => { + const harness = harnessWith() + const session = harness.session('main') + await session.query('hold') + const running = collect(session.receiveResponse()) + await session.query('/compact') + const command = await collect(session.receiveResponse()) + expect(command.at(-1)).toEqual({ commandId: 'command-2', result: { kind: 'error', text: 'busy' } }) + expect(await harness.client.request('test/state')).toMatchObject({ waitingPrompts: 1 }) + await harness.client.request('test/release') + expect(finalRun(await running).finalResponse).toBe('reply:hold') +}) + +it('retains a rejection before reception without creating an unhandled rejected promise', async () => { + const harness = harnessWith({ QUERY_ERROR_TEXT: 'reject this query' }) + const session = harness.session('main') + await session.query('reject this query') + await harness.client.request('test/state') + await session.query('after rejection') + await expect(collect(session.receiveResponse())).rejects.toThrow(JsonRpcResponseError) + expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:after rejection') +}) + +it('accepts ordinary prompt routing for other slash input and preserves its text', async () => { + const session = harnessWith().session('main') + await session.query('/plan') + expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:/plan') + await session.query(' /compact extra ') + expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply: /compact extra ') +}) + +it('returns a valid command result even when the peer emits no lifecycle notifications', async () => { + const execution = { commandId: 'command-no-events', result: { kind: 'success' } } + const session = harnessWith({ QUERY_RESULT: JSON.stringify({ kind: 'command', execution }) }).session('main') + await session.query('/compact') + expect(await collect(session.receiveResponse())).toEqual([execution]) +}) + +it('ends a command observation at its own done event when reception happens after another query', async () => { + const harness = harnessWith() + const session = harness.session('main') + await session.query('/compact') + await session.query('later') + await harness.client.request('test/state') + const command = await collect(session.receiveResponse()) + expect(command).toHaveLength(3) + expect(command.at(-1)).toMatchObject({ commandId: 'command-1' }) + expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:later') +}) + +it('keeps a completed command result when the peer disconnects before reception', async () => { + const harness = harnessWith() + const session = harness.session('main') + await session.query('/compact') + await expect(harness.client.request('test/disconnect')).rejects.toThrow(TransportClosedError) + const command = await collect(session.receiveResponse()) + expect(command).toHaveLength(3) + expect(command.at(-1)).toMatchObject({ commandId: 'command-1', result: { kind: 'success', text: 'compacted' } }) +}) + +it('captures notification collection failures before a command receiver consumes the outcome', async () => { + const harness = harnessWith() + const subscribe = harness.client.subscribeSessionTree.bind(harness.client) + vi.spyOn(harness.client, 'subscribeSessionTree').mockImplementation((id) => { + const subscription = subscribe(id) + vi.spyOn(subscription, 'tryNext').mockImplementation(() => { throw new Error('notification collection failed') }) + return subscription + }) + const session = harness.session('main') + await session.query('/compact') + await harness.client.request('test/state') + await expect(collect(session.receiveResponse())).rejects.toThrow('notification collection failed') +}) + +it.each([ + null, + {}, + { kind: 'unknown' }, + { kind: 'prompt' }, + { kind: 'prompt', messageId: '' }, + { kind: 'command', execution: { commandId: 'c', result: { kind: 'error' } } }, +])('rejects malformed routing results on reception: %j', async (result) => { + const session = harnessWith({ QUERY_RESULT: JSON.stringify(result) }).session('main') + await session.query('input') + await expect(collect(session.receiveResponse())).rejects.toThrow(SdkProtocolError) +}) + +it('abandons a receiver returned before its first next without cancelling the server operation', async () => { + const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }) + const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') + const session = harness.session('main') + await session.query('/compact') + const created = subscribed.mock.results[0] + if (created?.type !== 'return') throw new Error('query did not create a subscription') + const close = vi.spyOn(created.value, 'close') + const receiver = session.receiveResponse() + await receiver.return?.() + expect(close).toHaveBeenCalled() + expect(await receiver.next()).toEqual({ done: true, value: undefined }) + expect(await harness.client.request('test/state')).toMatchObject({ waitingCommands: 1 }) + await harness.client.request('test/release') + await session.query('after abandonment') + expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:after abandonment') +}) + +it.each(['hold', '/compact'])('return releases a pending next for %s without cancelling runtime work', async (text) => { + const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }) + const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') + const session = harness.session('main') + await session.query(text) + const waiting = Promise.withResolvers() + if (text === 'hold') { + const created = subscribed.mock.results[0] + if (created?.type !== 'return') throw new Error('query did not create a subscription') + const subscription = created.value + const next = subscription.next.bind(subscription) + // Returning from a yielded buffered frame does not exercise cancellation of an actual read. + vi.spyOn(subscription, 'next').mockImplementation(() => { + const queued = subscription.tryNext() + if (queued !== undefined) return Promise.resolve(queued) + const result = next() + waiting.resolve(undefined) + return result + }) + } + const receiver = session.receiveResponse() + const consuming = collect(receiver) + if (text === 'hold') await waiting.promise + else await harness.client.request('test/state') + await receiver.return?.() + await consuming + expect(await harness.client.request('test/state')).toMatchObject( + text === 'hold' ? { waitingPrompts: 1 } : { waitingCommands: 1 }, + ) + await harness.client.request('test/release') +}) + +it('ends a receiver closed during a pre-receipt read without yielding a RunResult', async () => { + const harness = harnessWith() + const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') + const session = harness.session('main') + await session.query('hold') + const created = subscribed.mock.results[0] + if (created?.type !== 'return') throw new Error('query did not create a subscription') + const next = created.value.next.bind(created.value) + const arrived = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + vi.spyOn(created.value, 'next').mockImplementationOnce(async () => { + const notification = await next() + arrived.resolve(undefined) + await release.promise + return notification + }) + const receiver = session.receiveResponse() + const receiving = receiver.next() + await arrived.promise + const returning = receiver.return?.() + release.resolve(undefined) + await expect(receiving).resolves.toEqual({ done: true, value: undefined }) + await expect(returning).resolves.toEqual({ done: true, value: undefined }) + expect(await harness.client.request('test/state')).toMatchObject({ waitingPrompts: 1 }) + await harness.client.request('test/release') +}) + +it('closes prompt subscriptions before yielding the final RunResult', async () => { + const harness = harnessWith() + const subscribed = vi.spyOn(harness.client, 'subscribeSessionTree') + const session = harness.session('main') + await session.query('complete') + const created = subscribed.mock.results[0] + if (created?.type !== 'return') throw new Error('query did not create a subscription') + const close = vi.spyOn(created.value, 'close') + const receiver = session.receiveResponse() + while (true) { + const item = await receiver.next() + if (item.done) throw new Error('query ended without a RunResult') + if (!('finalResponse' in item.value)) continue + expect(close).toHaveBeenCalled() + expect(item.value.finalResponse).toBe('reply:complete') + break + } + await receiver.return?.() +}) + +it('reports local request timeout from the receiver and leaves the runtime responsive', async () => { + const harness = harnessWith({ QUERY_HOLD_COMMAND: '1' }, 50) + const session = harness.session('main') + await session.query('/compact') + await expect(collect(session.receiveResponse())).rejects.toThrow(RequestTimeoutError) + expect(await harness.client.request('test/state', undefined, 5000)).toMatchObject({ waitingCommands: 1 }) +}) + +it('reports runtime loss from the receiver', async () => { + const session = harnessWith({ QUERY_DISCONNECT: '1' }).session('main') + await session.query('disconnect') + await expect(collect(session.receiveResponse())).rejects.toThrow(TransportClosedError) +}) diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 3baea7687f..5ed81e386e 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -63,6 +63,52 @@ async function tempDir(prefix: string): Promise { } describe('DeepSeekHarness', () => { + it('returns a command result without waiting for turn or idle notifications', async () => { + const harness = harnessWith() + await harness.start() + const session = harness.session('commands') + expect(await harness.client.executeCommand(session.id, ' /compact ')).toEqual({ + commandId: 'command-1', result: { kind: 'success', text: ' /compact ' }, + }) + expect((await session.run('/compact')).finalResponse).toBe('hello from fake runtime') + }) + + it.each([ + { kind: 'success' }, + { kind: 'success', text: 'compacted', sourceEventSeq: 0 }, + { kind: 'error', text: 'busy' }, + ])('preserves the native command outcome %j', async (result) => { + const execution = { commandId: 'command-2', result } + const harness = harnessWith({ FAKE_COMMAND_RESULT: JSON.stringify(execution) }) + await harness.start() + expect(await harness.client.executeCommand('commands', '/compact')).toEqual(execution) + }) + + it.each([ + null, + {}, + { commandId: '', result: { kind: 'success' } }, + { commandId: 'c', result: { kind: 'unknown' } }, + { commandId: 'c', result: { kind: 'success', text: 1 } }, + { commandId: 'c', result: { kind: 'success', text: null } }, + { commandId: 'c', result: { kind: 'success', sourceEventSeq: -1 } }, + { commandId: 'c', result: { kind: 'success', sourceEventSeq: '1' } }, + { commandId: 'c', result: { kind: 'success', sourceEventSeq: null } }, + { commandId: 'c', result: { kind: 'success', sourceEventSeq: Number.MAX_SAFE_INTEGER + 1 } }, + { commandId: 'c', result: { kind: 'error' } }, + { commandId: 'c', result: { kind: 'error', text: ' ' } }, + ])('rejects an invalid command response %j', async (result) => { + const harness = harnessWith({ FAKE_COMMAND_RESULT: JSON.stringify(result) }) + await harness.start() + await expect(harness.client.executeCommand('commands', '/compact')).rejects.toThrow(SdkProtocolError) + }) + + it('propagates command rejection without submitting a prompt', async () => { + const harness = harnessWith({ FAKE_COMMAND_ERROR: '1', FAKE_HANG_PROMPT: '1' }) + await harness.start() + await expect(harness.client.executeCommand('commands', '/plan')).rejects.toMatchObject({ code: -32603, message: 'command unavailable' }) + }) + it('returns a restored handle that can run followups on the same identity', async () => { const harness = harnessWith() const session = await harness.resumeSession('saved-session') diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 2aed1dc438..4e03357fd8 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: a11a3be122c6418e - zh: 134659fa57d64199 + en: 54c01fad1cfcf11d + zh: f30ae08e73474380 /deepseek-ai-dsh-sdk-protocol/use-this-package/native-user-questions: en: 9dcd999ae8c66b71 zh: 174c5fe0cbb0a9e4 diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 93a34454af..81e0cf116f 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -41,6 +41,8 @@ Both wire ends share one method set: client-to-server requests, opt-in server-to |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) | +| client→server | `session/query` | `SessionQueryParams` → `SessionQueryResult` | +| client→server | `session/command` | `SessionCommandParams` → `CommandExecution` | | client→server | `session/resume` | `SessionResumeParams` → `{}` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | @@ -58,6 +60,10 @@ Both wire ends share one method set: client-to-server requests, opt-in server-to `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/query` accepts exactly `{ sessionId, text, delivery }`, where text is non-blank and delivery is `queue` or `steer`. The runtime replies with `{ kind: "prompt", messageId }` for an ordinary input, or `{ kind: "command", execution }` after a supported command settles. A prompt receipt is not a model result; command completion does not require an inbox receipt or idle notification. Recognition and the supported command set belong to the [SDK server](../server/README.md). + +`session/command` accepts exactly `{ sessionId, line }` after initialization and executes only `/compact` through the composed native command registry. It returns the native `CommandExecution` after the handler settles, including business failures as `result.kind: "error"`; unavailable or unsupported commands and malformed requests reject with a JSON-RPC error. There is no ordinary-prompt fallback or attachment input. Command lifecycle notifications precede the response; no inbox receipt or ordinary turn is required. `session/prompt` remains unchanged. + `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. diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index f81f58c223..89fea62325 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -41,6 +41,8 @@ kind: "package-library" |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) | +| client→server | `session/query` | `SessionQueryParams` → `SessionQueryResult` | +| client→server | `session/command` | `SessionCommandParams` → `CommandExecution` | | client→server | `session/resume` | `SessionResumeParams` → `{}` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | @@ -58,6 +60,10 @@ kind: "package-library" `session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。 +`session/query` 接受且仅接受 `{ sessionId, text, delivery }`,text 不能全为空白,delivery 为 `queue` 或 `steer`。普通输入返回 `{ kind: "prompt", messageId }`,受支持命令结束后返回 `{ kind: "command", execution }`。Prompt 回执不是模型结果,命令完成也不要求 inbox 回执或 idle 通知。识别规则及受支持命令由 [SDK 服务端](../server/README.zh.md)定义。 + +`session/command` 在初始化后接受且仅接受 `{ sessionId, line }`,通过已组成的原生命令注册器执行 `/compact`。Handler 结束后返回原生 `CommandExecution`,业务失败以 `result.kind: "error"` 返回;命令不可用、不受支持或请求格式错误时返回 JSON-RPC 错误。不回退为普通 prompt,也不接受附件。命令生命周期通知在响应前到达,不要求 inbox 回执或普通 turn。`session/prompt` 保持不变。 + `session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{}`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 70b670cb65..9568247b84 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -26,6 +26,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", @@ -33,6 +34,7 @@ "@deepseek-ai/dsh-user-questions": "workspace:*" }, "devDependencies": { + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts index ef1c71c5e0..83131a8c00 100644 --- a/packages/sdk/protocol/src/index.ts +++ b/packages/sdk/protocol/src/index.ts @@ -13,6 +13,7 @@ export type { JsonRpcTransportPeer } from './transport.ts' export type { ApprovalRequestParams, ApprovalRequestResult, + CommandExecution, HarnessSdkClientRequestMap, HarnessSdkNotificationMap, HarnessSdkRequestMap, @@ -26,6 +27,9 @@ export type { SdkUserQuestionAnswer, SdkUserQuestionCancelled, SessionEventNotification, + SessionCommandParams, + SessionQueryParams, + SessionQueryResult, SessionStatusNotification, SessionPromptParams, SessionPromptResult, diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index cfa746cac2..e917890bf8 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -9,7 +9,8 @@ */ import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { CommandExecution } from '@deepseek-ai/dsh-commands/types' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { AskUserQuestionAnswer, AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types' @@ -89,6 +90,32 @@ export interface SessionPromptResult { messageId: string } +/** Strict command invocation; the SDK currently supports only `/compact`. */ +export interface SessionCommandParams { + /** SDK-owned Session identity; an unknown id creates its Agent. */ + sessionId: SessionId + /** Complete command line; invalid arguments are handled by the native command. */ + line: string +} + +/** Text submitted through the SDK's ordinary-input and slash-command entry. */ +export interface SessionQueryParams { + /** SDK-owned Session identity; an unknown id creates its Agent. */ + sessionId: SessionId + /** Non-blank input text, retained verbatim when it is an ordinary prompt. */ + text: string + /** Ordinary prompt delivery; commands keep their native execution timing. */ + delivery: 'queue' | 'steer' +} + +/** A prompt's enqueue receipt or a settled native command; neither means all Session work ended. */ +export type SessionQueryResult = + | ({ kind: 'prompt' } & SessionPromptResult) + | { kind: 'command'; execution: CommandExecution } + +/** Native command outcome, including its logged lifecycle identity. */ +export type { CommandExecution } + /** Restore a persisted session without enqueueing a prompt. */ export interface SessionResumeParams { /** Persisted session identity; missing or already owned sessions are rejected. */ @@ -177,6 +204,8 @@ export interface HarnessSdkNotificationMap { export interface HarnessSdkRequestMap { 'initialize': { params: InitializeParams; result: InitializeResult } 'session/prompt': { params: SessionPromptParams; result: SessionPromptResult } + 'session/command': { params: SessionCommandParams; result: CommandExecution } + 'session/query': { params: SessionQueryParams; result: SessionQueryResult } 'session/resume': { params: SessionResumeParams; result: Record } 'session/wait': { params: SessionWaitParams; result: Record } 'session/working-directory/get': { params: SessionWorkingDirectoryParams; result: SessionWorkingDirectoryResult } diff --git a/packages/sdk/protocol/tsconfig.json b/packages/sdk/protocol/tsconfig.json index 415bb32b7f..79a1983c31 100644 --- a/packages/sdk/protocol/tsconfig.json +++ b/packages/sdk/protocol/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../interaction/user-questions" + }, + { + "path": "../../interaction/commands" } ] } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index b56838412f..e7c82ec70e 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: 20c7078553b09b26 - zh: 74d73396af3fb929 + en: 1d441cdad2ebeb6d + zh: 0afccd29fe853d8a /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 @@ -60,14 +60,26 @@ en: 04d1b73cd8e06dfe zh: cd67fbbfdf39f776 /deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/sdk-user-message/what-the-model-sees: - en: 2b5a80ec4b4df11e - zh: e459fe77cc14d9b8 + en: ee8630f8f5eeda84 + zh: b4f6b0b45fa2b9c2 /deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/sdk-user-message/token-effect: en: 372ed851986b76d2 zh: 075b94a565275368 /deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/sdk-user-message/kv-cache-effect: en: f5d5f97a153f7c70 zh: f681f89596f500f5 +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/manual-compaction: + en: fa59bcc530a2f385 + zh: 4b17b38c0b30e85f +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/manual-compaction/what-the-model-sees: + en: 2be503bfb0db7b70 + zh: 1e0e2da4a8bc243b +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/manual-compaction/token-effect: + en: 8f0d7b5a80cc1240 + zh: 2c28370ab2ff5556 +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/manual-compaction/kv-cache-effect: + en: 6575950aede7f71e + zh: bfbfbba8b10fc448 /deepseek-ai-dsh-sdk-jsonrpc-server/known-limitations-and-deferred-work: en: a6d0b12d02961572 zh: 92822e033db55980 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index e9007e593c..49dea4f8f4 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -33,6 +33,10 @@ Set `initialize.approvalRequests` to `true` to send internal approvals as server The plugin publishes the `sdkSessionActivation` readiness marker once its reminder activation handler is installed; an SDK composition injects this marker into Schedule instead of the Web Session controller. Scheduled delivery uses this same SDK Session owner. Due startup reminders wait for successful SDK initialization, then resume with its provider, model, reasoning effort, and output limit. The client can resume a reminder-restored Session once; concurrent reminder and client restoration share one handle. Shutdown releases reminders waiting for initialization and drains accepted restorations. +`session/query` is the unified text entry. Ordinary text retains its original content and uses the selected queue/steer operation; the default is supplied explicitly by the client. The SDK recognizes `/compact` and its registered Desktop alias, then reuses the native command path. `/compact extra`, unknown slash tokens and paths remain ordinary prompts. All other slash input, including registered commands not yet supported and UI names such as `/model`, `/file` and `/export`, keeps the existing ordinary-prompt behavior. If no compact command is composed, that input also remains ordinary text; a failure after compact has been selected never falls back to a prompt. Query does not change permissions. Inputs are text-only; `session/prompt` retains its existing content-block API. + +`session/command` accepts `{ sessionId, line }` and currently exposes `/compact` only. The full sdk profile already composes its native handler; profiles without that command reject the call. The server resolves its own live Agent and calls `commands.execute()` without queueing a prompt or waiting for idle. The command owns its model request, history replacement and checkpoint; an active Agent receives the native busy result. Shutdown cancels pending commands and drains SDK command waiters before releasing Agents. A timeout does not establish rollback, and clients must not retry automatically. See the [protocol](../protocol/README.md) for results and errors. + `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. ### Wiring @@ -120,7 +124,7 @@ Read these pages when the plugin contract is not enough. They move from the wire #### What the model sees -For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. +Ordinary `session/query` text and accepted `session/prompt` content enter a user message verbatim. Query delivery selects a queued turn or the next available step, without changing permissions. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. #### Token effect @@ -130,6 +134,20 @@ Data-dependent user-message tokens enter retained session history and are resent Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Manual compaction + +#### What the model sees + +The command line and result stay outside model messages. The existing [compact command](../../compaction/command-compact/README.md#model-experience) issues the auxiliary summary request and replaces selected history with its logged checkpoint. It adds no ordinary turn. + +#### Token effect + +The compaction owner pays the summary request cost and reduces later history tokens; SDK framing adds none. + +#### KV Cache effect + +The replaced history changes the reusable prefix according to the native compactor. The SDK adds no separate invalidation. + ## Known Limitations and Deferred Work diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 56ae2f8bdf..4e9722ef19 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -33,6 +33,10 @@ kind: "package-reference" 插件在注册提醒激活处理方后发布 `sdkSessionActivation` 就绪标记;SDK 组合让 Schedule 依赖此标记,而非 Web Session controller。定时提醒也通过同一个 SDK Session 所有者投递。启动时已到期的提醒等待 SDK 成功初始化,再使用初始化指定的提供方、模型、推理强度和输出上限恢复会话。客户端可以首次接管提醒恢复的 Session;并发的提醒与客户端恢复共用一个 handle。关闭时释放等待初始化的提醒,并等待已经接纳的恢复完成。 +`session/query` 是统一文本入口。普通文本保留原文,使用选定 queue/steer 操作,默认值由客户端显式发送。SDK 识别 `/compact` 及绑定到其当前定义的 Desktop 别名,再复用原生命令路径。`/compact extra`、未知 slash token 和路径仍为普通 prompt。其他 slash 输入,包括已注册但尚不支持的命令及 `/model`、`/file`、`/export` 等 UI 名称,保持原有普通 prompt 行为。未组成 compact 命令时,该输入也保留为普通文本;一旦选中 compact,执行失败不会再回退为 prompt。Query 不改变权限。此入口仅接受文本,`session/prompt` 保留已有内容块 API。 + +`session/command` 接受 `{ sessionId, line }`,目前仅开放 `/compact`。完整 sdk profile 已组成其原生 handler,缺少该命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。模型请求、历史替换和 checkpoint 由命令所有者处理;活动 Agent 返回原生 busy 结果。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不代表回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 + `session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 ### 组装 @@ -120,7 +124,7 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 #### 模型看到什么 -对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 +普通 `session/query` 文本和已接受的 `session/prompt` 内容原样进入用户消息。Query 递送选择排队的新 turn 或下个可用 step,不改变权限。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 #### Token 影响 @@ -130,6 +134,20 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 手动压缩 + +#### 模型看到什么 + +命令行和结果不进入模型消息。现有[compact 命令](../../compaction/command-compact/README.zh.md#model-experience)发起辅助摘要请求,并将选定历史替换为已记录的 checkpoint,不增加普通 turn。 + +#### Token 影响 + +摘要请求成本和后续历史 token 减少由压缩所有者决定,SDK 分帧不增加模型 token。 + +#### KV Cache 影响 + +历史替换按原生压缩器规则改变可复用前缀,SDK 不增加独立失效。 + ## 已知限制与延期工作 diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 0811e092a8..f90fc860d0 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", @@ -54,6 +55,9 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-command-compact": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", @@ -63,6 +67,8 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" diff --git a/packages/sdk/server/src/query-input.ts b/packages/sdk/server/src/query-input.ts new file mode 100644 index 0000000000..5983d6381a --- /dev/null +++ b/packages/sdk/server/src/query-input.ts @@ -0,0 +1,18 @@ +/** Desktop text-input recognition for the SDK; command execution stays with the native registry. */ + +import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' + +/** + * Recognize the supported bare compact form; all other input retains ordinary prompt behavior. + * @param text - original input; the caller preserves it on the ordinary-prompt path. + * @param commands - exact receiving Agent's current command descriptors. + * @returns `/compact` for its effective name or Desktop alias, otherwise undefined. + */ +export function queryCommand(text: string, commands: readonly CommandDescriptor[]): string | undefined { + const trimmed = text.trim() + if (!trimmed.startsWith('/') || /\s/u.test(trimmed)) return undefined + const name = trimmed.slice(1) + const descriptor = commands.find(command => command.name === name) + ?? (name === '压缩' ? commands.find(command => command.definitionId === '@deepseek-ai/dsh-command-compact') : undefined) + return descriptor?.name === 'compact' ? '/compact' : undefined +} diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index 0dceccc59c..d374124488 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -8,7 +8,9 @@ import type { Context } from '@deepseek-ai/cordis' import { resolve } from 'node:path' import { SdkUserQuestionBridge } from './user-questions.ts' +import { queryCommand } from './query-input.ts' import { brandString } from '@deepseek-ai/dsh-brand' +import { parseCommand } from '@deepseek-ai/dsh-commands' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { admitEncodedImages, type EncodedImageAttachment, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ReasoningEffortId, type ContentBlock, type LlmRuntime } from '@deepseek-ai/dsh-llm' @@ -23,10 +25,14 @@ import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import type { ApprovalRequestParams, + CommandExecution, InitializeParams, InitializeResult, JsonRpcTransportPeer, SessionEventNotification, + SessionCommandParams, + SessionQueryParams, + SessionQueryResult, SessionPromptParams, SessionPromptResult, SessionResumeParams, @@ -94,6 +100,7 @@ export class HarnessSdkJsonRpcServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() + private readonly commandTasks = new Set>() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -251,13 +258,19 @@ export class HarnessSdkJsonRpcServer { * @returns the durable message identity. */ async prompt(params: SessionPromptParams): Promise { + return this.submitPrompt(params, 'queue') + } + + private async submitPrompt(params: SessionPromptParams, delivery: 'queue' | 'steer'): Promise { if (!this.initialized) throw new Error('SDK server is not initialized') const rec = await this.getOrCreateSession(params.sessionId) + this.lifetime.signal.throwIfAborted() // An agent-loop-only reload disposes the loop's agents while this record // survives; a retained agent accepts followup() silently, so validate the // record against the live registry before delivery. this.assertLiveAgent(rec, params.sessionId) const content = await durablePromptContent(this.ctx, params.contentBlocks) + this.lifetime.signal.throwIfAborted() // Attachment admission crosses an async boundary where shutdown or an // agent-loop reload may detach the retained handle. this.assertLiveAgent(rec, params.sessionId) @@ -265,10 +278,71 @@ export class HarnessSdkJsonRpcServer { content, source: { kind: 'user' }, }) - rec.handle.agent.followup(message) + if (delivery === 'steer') rec.handle.agent.steer(message) + else rec.handle.agent.followup(message) return { messageId: message.id } } + /** + * Dispatch text as a Desktop-style input without changing Session permissions. + * @param params - text, receiving Session and ordinary-prompt delivery. + * @returns an enqueue receipt for text or the native command result for `/compact`. + * @throws on runtime failure; a recognized compact failure never falls back to a prompt. + */ + async query(params: SessionQueryParams): Promise { + if (!this.initialized) throw new Error('SDK server is not initialized') + if (this.shuttingDown) throw new Error('SDK server is shutting down') + if (params.text.trim().startsWith('/')) { + const commands = this.ctx.get('commands') + if (commands !== undefined) { + const rec = await this.getOrCreateSession(params.sessionId) + this.lifetime.signal.throwIfAborted() + this.assertLiveAgent(rec, params.sessionId) + const line = queryCommand(params.text, commands.list(rec.handle.agent)) + if (line !== undefined) { + return { kind: 'command', execution: await this.executeCommand({ sessionId: params.sessionId, line }) } + } + } + } + const receipt = await this.submitPrompt({ + sessionId: params.sessionId, + contentBlocks: [{ type: 'text', text: params.text }], + }, params.delivery) + return { kind: 'prompt', ...receipt } + } + + /** + * Execute `/compact` through the native command registry without enqueueing a prompt. + * @param params - target Session and complete command line. + * @returns the native handler result; command errors remain result values. + * @throws for unsupported input, unavailable commands, or runtime/transport failure. + */ + async executeCommand(params: SessionCommandParams): Promise { + if (!this.initialized) throw new Error('SDK server is not initialized') + if (this.shuttingDown) throw new Error('SDK server is shutting down') + if (parseCommand(params.line)?.name !== 'compact') { + throw new TypeError('The SDK command endpoint supports only /compact') + } + const commands = this.ctx.get('commands') + if (commands === undefined) { + throw new Error('Commands are unavailable in this SDK profile') + } + const rec = await this.getOrCreateSession(params.sessionId) + this.lifetime.signal.throwIfAborted() + this.assertLiveAgent(rec, params.sessionId) + const task = commands.execute(rec.handle.agent, params.line, [], this.lifetime.signal) + this.commandTasks.add(task) + try { + const result = await task + if (result === undefined) { + throw new Error('/compact is unavailable in this SDK profile') + } + return result + } finally { + this.commandTasks.delete(task) + } + } + /** * 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. @@ -385,6 +459,7 @@ export class HarnessSdkJsonRpcServer { this.initialization.resolve() const pendingCreations = [...this.sessionCreations.values()] await Promise.allSettled(pendingCreations) + await Promise.allSettled([...this.commandTasks]) this.sessionCreations.clear() this.scheduledSessions.clear() const records = [...this.sessions.values()] @@ -428,6 +503,23 @@ export class HarnessSdkJsonRpcServer { return this.initialize(params as unknown as InitializeParams) case 'session/prompt': return this.prompt(params as unknown as SessionPromptParams) + case 'session/query': { + if (typeof params?.sessionId !== 'string' || params.sessionId.length === 0 + || typeof params.text !== 'string' || params.text.trim().length === 0 + || (params.delivery !== 'queue' && params.delivery !== 'steer') + || Object.keys(params).some(key => key !== 'sessionId' && key !== 'text' && key !== 'delivery')) { + throw new TypeError('session/query requires only a non-empty sessionId, non-blank text and queue/steer delivery') + } + return this.query({ sessionId: brandString(params.sessionId), text: params.text, delivery: params.delivery }) + } + case 'session/command': { + if (typeof params?.sessionId !== 'string' || params.sessionId.length === 0 + || typeof params.line !== 'string' + || Object.keys(params).some(key => key !== 'sessionId' && key !== 'line')) { + throw new TypeError('session/command requires only a non-empty sessionId and a line string') + } + return this.executeCommand({ sessionId: brandString(params.sessionId), line: params.line }) + } case 'session/resume': { const sessionId = params?.sessionId if (typeof sessionId !== 'string' || sessionId.length === 0) { diff --git a/packages/sdk/server/tests/commands.spec.ts b/packages/sdk/server/tests/commands.spec.ts new file mode 100644 index 0000000000..8376c2a60a --- /dev/null +++ b/packages/sdk/server/tests/commands.spec.ts @@ -0,0 +1,252 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import CommandRuntime, { type CommandDefinition, type CommandInvocation } from '@deepseek-ai/dsh-commands' +import { LlmAdapter, type GenerateOptions, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { HarnessSdkJsonRpcServer } from '../src/server.ts' + +class UnusedModel extends LlmAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model }) + } + + async *stream(): AsyncIterable { + throw new Error('Command dispatch must not call the model') + } +} + +class TextModel extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model }) + } + + override async *stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'done' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'done' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +async function setup(registry = true) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + const loop = await ctx.plugin(AgentLoop, { agents: [] }) + if (registry) await ctx.plugin(CommandRuntime) + ctx.llm.registerAdapter(['mock'], new UnusedModel()) + const transport: JsonRpcTransportPeer = { + request: async () => { throw new Error('Unexpected runtime request') }, + notify: () => {}, + } + const server = new HarnessSdkJsonRpcServer(ctx, transport) + onTestFinished(async () => { await server.shutdown() }) + const initialize = () => server.initialize({ cwd: process.cwd(), provider: 'mock', model: 'mock' }) + const register = (handler: CommandDefinition['handler']) => { + ctx.effect(() => ctx.commands.register({ name: 'compact', description: 'Fixture command', handler })) + } + const command = () => server.executeCommand({ sessionId: SessionId('commands'), line: '/compact' }) + return { ctx, server, loop, initialize, register, command } +} + +describe('SDK command dispatch', () => { + it.each(['success', 'error'] as const)('returns the logged native %s without a model turn', async (kind) => { + const h = await setup() + await h.initialize() + const handler = vi.fn((_invocation: CommandInvocation) => ({ kind, text: 'native outcome' })) + h.register(handler) + const execution = await h.command() + const agent = h.ctx.agents.get(SessionId('commands'))! + expect(handler).toHaveBeenCalledOnce() + const invocation = handler.mock.calls[0]![0] + expect(invocation.agent.id).toBe(agent.id) + expect(invocation.rawInput).toBe('') + expect(invocation.attachments).toEqual([]) + expect(execution.result).toEqual({ kind, text: 'native outcome' }) + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(agent.session.snapshotEvents().map(event => event.data)).toEqual([ + { commandId: execution.commandId, name: 'compact', args: '', source: { kind: 'user' } }, + { commandId: execution.commandId, kind, text: 'native outcome' }, + ]) + }) + + it.each([undefined, {}, { sessionId: '' }, { sessionId: 's', line: 1 }, + { sessionId: 's', line: '/compact', attachments: [] }])('rejects malformed wire parameters %j before creating a Session', async (params) => { + const h = await setup() + await h.initialize() + await expect(h.server.handleRequest('session/command', params)).rejects.toThrow('requires only') + expect(h.ctx.agents.list()).toEqual([]) + }) + + it.each(['text', '/plan', '/unknown'])('rejects unsupported input %s without invoking another command', async (line) => { + const h = await setup() + await h.initialize() + const handler = vi.fn(() => ({ kind: 'success' as const })) + h.register(handler) + await expect(h.server.handleRequest('session/command', { sessionId: 's', line })).rejects.toThrow('only /compact') + expect(handler).not.toHaveBeenCalled() + expect(h.ctx.agents.list()).toEqual([]) + }) + + it('requires initialization and the composed registry and command', async () => { + const absent = await setup(false) + await expect(absent.command()).rejects.toThrow('not initialized') + await absent.initialize() + await expect(absent.command()).rejects.toThrow('Commands are unavailable') + const empty = await setup() + await empty.initialize() + await expect(empty.command()).rejects.toThrow('/compact is unavailable') + }) + + it('passes arguments unchanged and propagates handler failures', async () => { + const h = await setup() + await h.initialize() + h.register(({ rawInput }) => { throw new Error(`handler saw:${rawInput}`) }) + await expect(h.server.handleRequest('session/command', { sessionId: 'commands', line: '/compact extra' })) + .rejects.toThrow('handler saw: extra') + expect(h.ctx.agents.get(SessionId('commands'))!.session.snapshotEvents().at(-1)?.type).toBe('command/done') + }) + + it('rejects a disposed Agent retained by the SDK handle', async () => { + const h = await setup() + await h.initialize() + h.register(() => ({ kind: 'success' })) + await h.command() + await h.loop.dispose() + await expect(h.command()).rejects.toThrow('disposed outside the server') + await expect(h.server.query({ sessionId: SessionId('commands'), text: '/compact', delivery: 'queue' })) + .rejects.toThrow('disposed outside the server') + }) + + it('cancels an in-flight command on shutdown and rejects later admission', async () => { + const h = await setup() + await h.initialize() + const entered = Promise.withResolvers() + const cancelled = vi.fn() + h.register(({ signal }) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { cancelled(); reject(new Error('fixture command aborted')) }, { once: true }) + entered.resolve(undefined) + })) + const execution = h.command() + const rejected = expect(execution).rejects.toThrow('shutting down') + await entered.promise + await expect(h.server.getWorkingDirectory({ sessionId: 'commands' })).resolves.toHaveProperty('cwd') + await h.server.shutdown() + await rejected + expect(cancelled).toHaveBeenCalledOnce() + expect(h.ctx.agents.list()).toEqual([]) + await expect(h.command()).rejects.toThrow('shutting down') + }) + + it.each(['command', 'query-command', 'query-prompt'] as const)('does not execute %s while its Session is being created at shutdown', async (entry) => { + const h = await setup() + await h.initialize() + const handler = vi.fn(() => ({ kind: 'success' as const })) + h.register(handler) + const created = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + const create = h.ctx.agents.create.bind(h.ctx.agents) + vi.spyOn(h.ctx.agents, 'create').mockImplementation(async (request) => { + const handle = await create(request) + created.resolve(undefined) + await release.promise + return handle + }) + const execution = entry === 'command' ? h.command() + : h.server.query({ sessionId: SessionId('commands'), text: entry === 'query-command' ? '/compact' : 'ordinary input', delivery: 'queue' }) + const rejected = expect(execution).rejects.toThrow('shutting down') + await created.promise + const shutdown = h.server.shutdown() + release.resolve(undefined) + await Promise.all([rejected, shutdown]) + expect(handler).not.toHaveBeenCalled() + expect(h.ctx.agents.list()).toEqual([]) + }) +}) + +describe('SDK query dispatch', () => { + it.each([undefined, {}, { sessionId: '', text: 'go', delivery: 'queue' }, + { sessionId: 'q', text: '', delivery: 'queue' }, { sessionId: 'q', text: ' ', delivery: 'queue' }, + { sessionId: 'q', text: 'go', delivery: 'later' }, + { sessionId: 'q', text: 'go', delivery: 'queue', permission: 'full' }])('rejects invalid query fields %j', async (params) => { + const h = await setup() + await h.initialize() + await expect(h.server.handleRequest('session/query', params)).rejects.toThrow('session/query requires only') + expect(h.ctx.agents.list()).toEqual([]) + }) + + it('preserves ordinary input without a command registry and requires a live initialized server', async () => { + const h = await setup(false) + const params = { sessionId: SessionId('q'), text: '/compact', delivery: 'queue' as const } + await expect(h.server.query(params)).rejects.toThrow('not initialized') + const model = new TextModel() + h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model)) + await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' }) + await expect(h.server.query(params)).resolves.toMatchObject({ kind: 'prompt' }) + await h.server.wait({ sessionId: 'q' }) + expect(model.requests).toHaveLength(1) + await h.server.shutdown() + await expect(h.server.query(params)).rejects.toThrow('shutting down') + }) + + it('returns the native command result without creating a prompt', async () => { + const h = await setup() + await h.initialize() + h.register(() => ({ kind: 'error', text: 'native refusal' })) + const response = await h.server.handleRequest('session/query', { + sessionId: 'q', text: ' \n/compact\t ', delivery: 'queue', + }) + expect(response).toMatchObject({ kind: 'command', execution: { result: { kind: 'error', text: 'native refusal' } } }) + expect(h.ctx.agents.get(SessionId('q'))!.session.snapshotEvents().map(event => event.type)) + .toEqual(['command/run', 'command/done']) + }) + + it.each(['ordinary text', '/unknown', ' \n/compact extra\t '])('preserves unclaimed query text %j', async (text) => { + const h = await setup() + h.register(() => { throw new Error('Must not execute a command') }) + const model = new TextModel() + h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model)) + await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' }) + expect(await h.server.query({ sessionId: SessionId('q'), text, delivery: 'queue' })).toMatchObject({ kind: 'prompt' }) + await h.server.wait({ sessionId: 'q' }) + expect(model.requests).toHaveLength(1) + expect(model.requests[0]!.messages.some(message => message.role === 'user' + && message.content.some(block => block.type === 'text' && block.text === text))).toBe(true) + expect(h.ctx.agents.get(SessionId('q'))!.session.snapshotEvents().some(event => event.type === 'command/run')).toBe(false) + }) + + it('delivers a steer query through the existing Agent operation', async () => { + const h = await setup() + await h.initialize() + h.register(() => ({ kind: 'success' })) + await h.command() + const agent = h.ctx.agents.get(SessionId('commands'))! + const steer = vi.spyOn(agent, 'steer').mockImplementation(() => {}) + const queued = vi.spyOn(agent, 'followup').mockImplementation(() => {}) + const result = await h.server.query({ sessionId: SessionId('commands'), text: 'next step', delivery: 'steer' }) + expect(result).toMatchObject({ kind: 'prompt' }) + expect(steer).toHaveBeenCalledWith(expect.objectContaining({ content: [{ type: 'text', text: 'next step' }] })) + expect(queued).not.toHaveBeenCalled() + }) + + it.each(['/plan inspect', '/model', '/file', '/export'])('preserves existing prompt behavior for unimplemented input %s', async (text) => { + const h = await setup() + const model = new TextModel() + h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model)) + await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' }) + const handler = vi.fn(() => ({ kind: 'success' as const })) + h.ctx.effect(() => h.ctx.commands.register({ name: 'plan', description: 'Plan', input: { hint: 'Task' }, handler })) + await expect(h.server.query({ sessionId: SessionId('q'), text, delivery: 'queue' })).resolves.toMatchObject({ kind: 'prompt' }) + await h.server.wait({ sessionId: 'q' }) + expect(handler).not.toHaveBeenCalled() + expect(model.requests[0]!.messages.some(message => message.content.some(block => block.type === 'text' && block.text === text))).toBe(true) + expect(h.ctx.agents.get(SessionId('q'))!.session.snapshotEvents().some(event => event.type === 'command/run')).toBe(false) + }) +}) diff --git a/packages/sdk/server/tests/compact-parity.spec.ts b/packages/sdk/server/tests/compact-parity.spec.ts new file mode 100644 index 0000000000..ebbb93c485 --- /dev/null +++ b/packages/sdk/server/tests/compact-parity.spec.ts @@ -0,0 +1,130 @@ +/** Compare SDK compaction with the native command entry used by Desktop. */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } 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 CommandRuntime from '@deepseek-ai/dsh-commands' +import * as CommandCompact from '@deepseek-ai/dsh-command-compact' +import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import TokenMeter from '@deepseek-ai/dsh-token-meter' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { HarnessSdkJsonRpcServer } from '../src/server.ts' + +const HISTORY = 'Preserve the migration requirement: keep the existing public file format and validate the result. '.repeat(80) +const SUMMARY = 'Keep the existing public file format and validate the migration.' + +/** Request identities and cancellation signals are not model context. */ +function modelInput(options: GenerateOptions) { + return structuredClone({ + provider: options.provider, + model: options.model, + system: options.system, + messages: options.messages.map(message => ({ role: message.role, content: message.content })), + tools: options.tools, + toolHistory: options.toolHistory, + temperature: options.temperature, + reasoningEffort: options.reasoningEffort, + maxTokens: options.maxTokens, + stop: options.stop, + purpose: options.purpose, + }) +} + +class ScriptedModel extends LlmAdapter { + readonly requests: ReturnType[] = [] + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, context: { contextWindow: 100_000 } }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(modelInput(options)) + const text = options.purpose === 'compaction' ? SUMMARY : this.requests.length === 1 ? 'READY' : 'CONTINUED' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Both paths use the same real services and input delivery; only the command entry differs. */ +async function compactAndContinue(path: 'sdk' | 'native', cwd: string) { + const ctx = new Context() + let server: HarnessSdkJsonRpcServer | undefined + try { + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeter) + await ctx.plugin(BasicCompactionEngine, { auto: false }) + await ctx.plugin(CommandRuntime) + await ctx.plugin(CommandCompact) + ctx.effect(() => ctx.tools.register(defineTool({ + name: 'read_marker', + description: 'Read a marker from the workspace.', + parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: () => Promise.reject(new Error('The model script does not call tools')), + }))) + const model = new ScriptedModel() + ctx.effect(() => ctx.llm.registerAdapter(['compact-parity'], model)) + server = new HarnessSdkJsonRpcServer(ctx, { + request: async () => { throw new Error('The model script does not request user input') }, + notify: () => {}, + }) + await server.initialize({ cwd, provider: 'compact-parity', model: 'compact-parity' }) + const sessionId = SessionId('main') + await server.prompt({ sessionId, contentBlocks: [{ type: 'text', text: HISTORY }] }) + await server.wait({ sessionId }) + const agent = ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('SDK prompt did not create its Agent') + + const routed = path === 'sdk' + ? await server.query({ sessionId, text: '/compact', delivery: 'queue' }) + : undefined + const execution = path === 'sdk' + ? routed?.kind === 'command' ? routed.execution : undefined + : await ctx.commands.execute(agent, '/compact', [], new AbortController().signal) + expect(execution?.result.kind).toBe('success') + await server.prompt({ sessionId, contentBlocks: [{ type: 'text', text: 'Continue from the migration requirements.' }] }) + await server.wait({ sessionId }) + const events = agent.session.snapshotEvents() + return { + requests: model.requests, + turns: events.filter(event => event.type === 'turn/start').map(event => event.data.turn), + steps: events.filter(event => event.type === 'step/start').map(event => ({ turn: event.data.turn, step: event.data.step })), + compactionOwners: events.filter(event => event.type === 'compaction/start' || event.type === 'compaction/end') + .map(event => event.data.turn), + } + } finally { + try { + await server?.shutdown() + } finally { + await ctx.fiber.dispose() + } + } +} + +it('preserves native compact model inputs and turn boundaries through the SDK command entry', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-sdk-compact-parity-')) + try { + const native = await compactAndContinue('native', cwd) + const sdk = await compactAndContinue('sdk', cwd) + expect(sdk).toEqual(native) + expect(sdk.requests.map(request => request.purpose)).toEqual([undefined, 'compaction', undefined]) + expect(sdk.requests[0]?.tools?.map(tool => tool.name)).toEqual(['read_marker']) + expect(sdk.requests[2]?.messages.flatMap(message => message.content)) + .toEqual(expect.arrayContaining([{ type: 'text', text: SUMMARY }])) + expect(sdk.turns).toEqual([1, 2]) + expect(sdk.steps).toEqual([{ turn: 1, step: 1 }, { turn: 2, step: 1 }]) + expect(sdk.compactionOwners).toEqual([null, null]) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/sdk/server/tests/query-input.spec.ts b/packages/sdk/server/tests/query-input.spec.ts new file mode 100644 index 0000000000..3e9f43f45b --- /dev/null +++ b/packages/sdk/server/tests/query-input.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { CommandDefinitionId, type CommandDescriptor } from '@deepseek-ai/dsh-commands' +import { queryCommand } from '../src/query-input.ts' + +const commands: readonly CommandDescriptor[] = [ + { name: 'compact', definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), description: 'Compact' }, + { name: 'plan', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Plan', input: { hint: 'Task' } }, + { name: 'goal', definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'), description: 'Goal', input: { hint: 'Goal' } }, + { name: 'permission', definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), description: 'Permission', input: { hint: 'Preset' } }, + { name: 'feedback', definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-feedback'), description: 'Feedback', input: { hint: 'Feedback' } }, + { name: 'export', definitionId: CommandDefinitionId('@deepseek-ai/dsh-session-log-export'), description: 'Export' }, +] + +describe('SDK query text recognition', () => { + it.each([ + ['/compact', '/compact'], [' \n/compact \t', '/compact'], ['/压缩', '/compact'], + ])('recognizes the Desktop form %j', (text, expected) => { + expect(queryCommand(text, commands)).toBe(expected) + }) + + it.each(['ordinary text', '/', '/unknown', '/usr/bin', '/COMPACT', '/模型', '/文件', + '/compact extra', '/压缩 extra', '/export path', '/model extra', '/file path', + '/plan inspect', '/计划 inspect', '/goal finish', '/权限 read-only', '/反馈 problem', + '/导出', '/export', '/model', '/file'])('leaves %j as ordinary text', (text) => { + expect(queryCommand(text, commands)).toBeUndefined() + }) + + it('does not apply an alias to an unrelated scoped replacement', () => { + const replaced = [{ name: 'compact', description: 'Scoped command' }] + expect(queryCommand('/compact', replaced)).toBe('/compact') + expect(queryCommand('/压缩', replaced)).toBeUndefined() + }) + + it('leaves other registered commands and unavailable compact unchanged', () => { + expect(queryCommand('/plan', commands)).toBeUndefined() + expect(queryCommand('/compact', [])).toBeUndefined() + expect(queryCommand('/export', [])).toBeUndefined() + expect(queryCommand('/压缩', [{ name: 'prune', definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), description: 'Renamed command' }])) + .toBeUndefined() + }) + +}) diff --git a/packages/sdk/server/tsconfig.json b/packages/sdk/server/tsconfig.json index ccc747c940..5bb2b23171 100644 --- a/packages/sdk/server/tsconfig.json +++ b/packages/sdk/server/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../interaction/user-approval" + }, + { + "path": "../../interaction/commands" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a499ea0058..758883e516 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13365,6 +13365,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-commands': + specifier: workspace:* + version: link:../../interaction/commands '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -13405,6 +13408,15 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:* version: link:../../attachment/attachment + '@deepseek-ai/dsh-command-compact': + specifier: workspace:* + version: link:../../compaction/command-compact + '@deepseek-ai/dsh-commands': + specifier: workspace:* + version: link:../../interaction/commands + '@deepseek-ai/dsh-compaction-basic': + specifier: workspace:* + version: link:../../compaction/compaction-basic '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -13432,6 +13444,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../../subagent/subagent + '@deepseek-ai/dsh-token-meter': + specifier: workspace:* + version: link:../../llm/token-meter + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../../core/tools '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../../interaction/user-approval diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index ab3a87cf3e..6e50f3f36c 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -9,8 +9,11 @@ en: ddf4dabea526cef1 zh: 6e31cab10d2f3ed8 /deepseek-harness-python-sdk/start-a-runtime: - en: 1ae09bebf0f9e19b - zh: 55a0d557a2cae4d8 + en: c4c30913fe2642ef + zh: 0ed42bca37fba60b +/deepseek-harness-python-sdk/query-and-response: + en: b3ad6ea82350b467 + zh: 85dacc783fd12992 /deepseek-harness-python-sdk/client-approvals: en: fc69be1beb8abff1 zh: 91b56781931a6f68 diff --git a/python/sdk/README.md b/python/sdk/README.md index e57f83e2c9..b7d2252968 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -58,6 +58,17 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: result = session.run("Continue the task.") ``` + +## Query and response + +Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for the runtime's response. The runtime intercepts only `/compact` as a command; all other text, including `/plan` and other slash-prefixed input, remains an ordinary prompt. This input API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command. + +`receive_response()` claims the next query in send order. It first waits for the routing response, then yields `Notification` objects followed by one terminal value: `RunResult` for the prompt's inbox-receipt-to-idle interval, or `CommandExecution` after the command settles. Notifications can accumulate before receiving begins. Separate receivers can observe their queries concurrently, and another `query()` can be sent while a receiver waits. Close an unfinished iterator to release only its local observation; this does not cancel or roll back runtime work. Resources are released before yielding the terminal value. + +`CommandExecution` contains `command_id` and a `result` discriminated by `kind`. Success may include `text` and `source_event_seq`; an error includes `text`. Handler errors return `kind="error"`; routing, protocol and transport failures raise during receiving. Sending failures raise from `query()`. `request_timeout_seconds` bounds waiting for the routing response, not later prompt activity; an already-arrived response remains receivable after that deadline. + +The full `sdk` profile provides the intercepted `/compact` command. It can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. The default `sdk-minimal` profile does not provide command execution. `session.run("/compact")` remains ordinary prompt text. The lower-level blocking `HarnessClient.session_command(session_id, line)` remains available for strict command execution and rejects command names other than `/compact`. See the [full SDK compaction example](examples/README.md#compact-a-full-sdk-session). + ## Client approvals diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index f1396fa60e..5037b87308 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -58,6 +58,17 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: result = session.run("Continue the task.") ``` + +## 查询与接收 + +调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时响应。运行时只将 `/compact` 拦截为命令;包括 `/plan` 和其他以斜杠开头的输入在内,其余文本仍作为普通提示词。此输入 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。 + +`receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。 + +`CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。 + +完整 `sdk` profile 提供被拦截的 `/compact` 命令。它可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。默认 `sdk-minimal` profile 不提供命令执行能力。`session.run("/compact")` 仍按普通提示词文本处理。底层阻塞式 `HarnessClient.session_command(session_id, line)` 仍可用于严格命令执行,并拒绝 `/compact` 之外的命令名。另见[完整 SDK 压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 + ## 客户端审批 diff --git a/python/sdk/examples/README.i18n.yaml b/python/sdk/examples/README.i18n.yaml index cb789b7fcb..eb2644edc5 100644 --- a/python/sdk/examples/README.i18n.yaml +++ b/python/sdk/examples/README.i18n.yaml @@ -3,11 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk/examples/README.md /python-sdk-example: - en: d9dfc996ea2ace40 - zh: 33de241bad6c4ee1 + en: c97ffc5ce0f7db05 + zh: bdd91202075498a3 /python-sdk-example/run-the-minimal-agent: - en: f0d785582fe72a91 - zh: ee563fb17c775ed3 + en: 4b79fd6576e2759d + zh: 6258540acaf4bc37 +/python-sdk-example/compact-a-full-sdk-session: + en: 0bada22014bf5e6c + zh: 4308ea1a14b48f57 /python-sdk-example/add-plugins: en: 5e1e30dac13f6c9e zh: 0af98ebb75c88f43 diff --git a/python/sdk/examples/README.md b/python/sdk/examples/README.md index 74c799c9fc..80722e4d97 100644 --- a/python/sdk/examples/README.md +++ b/python/sdk/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runnable Python SDK example over the sole application launcher, `dsh --profile sdk-minimal`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, execution policy, and plugins. +Runnable Python SDK examples over the sole application launcher, `dsh`. The Python client owns JSON-RPC stdio; the selected profile owns the agent composition, persistence, execution policy, and plugins. ## Run the minimal agent @@ -29,6 +29,19 @@ The bundle does not include `dsh-base`, so every additional row is an explicit p The persistent PTY can modify any path available to the runtime process, so use a disposable checkout or container. + +## Compact a full SDK Session + +The [compaction example](compact.py) uses the full `sdk` profile. It reads `README.md` from the selected workspace into a `Session.query()` prompt, sends `/compact` through the same method, then asks a follow-up using the retained context. Each query is paired with `Session.receive_response()`. It uses the same model credentials and `DSH_MODEL` default as the minimal example, and requires an explicit home: + +```sh +python python/sdk/examples/compact.py \ + --dsh-home /absolute/path/to/compact-dsh-home \ + --workspace /absolute/path/to/disposable-workspace +``` + +The script consumes notifications and prints the command id and outcome separately from the model's responses. A failed turn or command stops the example. See [Query and response](../README.md#query-and-response) for completion and error behavior; `minimal.py` retains its `run()` entry and does not enable command execution. + ## Add plugins Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes: diff --git a/python/sdk/examples/README.zh.md b/python/sdk/examples/README.zh.md index 6a0b8f0ccf..5a208da7f6 100644 --- a/python/sdk/examples/README.zh.md +++ b/python/sdk/examples/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -基于唯一应用启动器 `dsh --profile sdk-minimal` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent(智能体)组合、持久化、执行策略与插件。 +基于唯一应用启动器 `dsh` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;所选 profile 负责 agent(智能体)组合、持久化、执行策略与插件。 ## 运行极简 agent @@ -29,6 +29,19 @@ python python/sdk/examples/minimal.py \ 持久 PTY 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 + +## 压缩完整 SDK Session + +[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.query()` 输入,通过同一方法发送 `/compact`,随后使用保留的上下文继续提问。每次 query 都配对调用 `Session.receive_response()`。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home: + +```sh +python python/sdk/examples/compact.py \ + --dsh-home /absolute/path/to/compact-dsh-home \ + --workspace /absolute/path/to/disposable-workspace +``` + +脚本消费通知,并将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[查询与接收](../README.zh.md#query-and-response);`minimal.py` 保留其 `run()` 入口,不启用命令执行。 + ## 添加插件 对同一个显式 home 使用运行时 wheel 包提供的 `dsh` 命令,以进行持久 profile 变更: diff --git a/python/sdk/examples/compact.py b/python/sdk/examples/compact.py new file mode 100644 index 0000000000..70831b7d8f --- /dev/null +++ b/python/sdk/examples/compact.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Compact a full SDK Session and continue with its retained context.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session + + +def receive(session: Session) -> None: + """Consume notifications and print the query's command or activity result.""" + for result in session.receive_response(): + if isinstance(result, RunResult): + if result.finish_reason != "completed": + raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}") + print(result.final_response) + elif isinstance(result, CommandExecution): + print(f"Command {result.command_id}: {result.result.kind}") + if result.result.kind == "error": + raise RuntimeError(result.result.text) + if result.result.text is not None: + print(result.result.text) + + +def main() -> None: + """Create conversation history, compact it, and ask a follow-up question.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dsh-home", required=True, type=Path) + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--session-id") + parser.add_argument("--provider", default="deepseek-official") + parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) + parser.add_argument("--max-tokens", type=int) + args = parser.parse_args() + + workspace = args.workspace.resolve() + readme = workspace / "README.md" + if not readme.is_file(): + parser.error("--workspace must contain README.md to provide conversation history") + + with DeepSeekHarness( + dsh_home=str(args.dsh_home.resolve()), + cwd=str(workspace), + profile="sdk", + provider=args.provider, + model=args.model, + max_tokens=args.max_tokens, + ) as harness: + session = harness.start_session(args.session_id) + session.query( + "Read this project README and summarize its purpose and development commands without using tools.\n\n" + + readme.read_text(encoding="utf-8") + ) + receive(session) + session.query("/compact") + receive(session) + session.query("What are the project's purpose and development commands? Use the earlier README without tools.") + receive(session) + + +if __name__ == "__main__": + main() diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py index 0a30ca830d..fdc6bda037 100644 --- a/python/sdk/src/deepseek_harness/__init__.py +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -2,7 +2,8 @@ from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session from .client import HarnessClient, HarnessConfig, UserQuestionHandler from .errors import SdkProtocolError from .models import ( - ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse, + ApprovalRequestParams, ApprovalRequestResult, CommandErrorResult, CommandExecution, + CommandResult, CommandSuccessResult, IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest, ) @@ -11,6 +12,10 @@ __all__ = [ "DeepSeekHarnessConfig", "Session", "RunResult", + "CommandExecution", + "CommandResult", + "CommandSuccessResult", + "CommandErrorResult", "HarnessClient", "HarnessConfig", "UserQuestionHandler", diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 4e5d99fd22..9fd5e1e88f 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -1,13 +1,16 @@ from __future__ import annotations import uuid +import threading +from collections import deque +from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path -from typing import Callable +from typing import Callable, Literal -from .client import HarnessClient, HarnessConfig, UserQuestionHandler -from .errors import SdkProtocolError -from .models import JsonObject, Notification +from .client import HarnessClient, HarnessConfig, NotificationSubscription, UserQuestionHandler, _PendingRequest +from .errors import SdkProtocolError, TransportClosedError +from .models import CommandExecution, JsonObject, Notification @dataclass(slots=True) @@ -48,6 +51,113 @@ class RunResult: notifications: list[Notification] +class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecution]): + def __init__( + self, client: HarnessClient, session_id: str, request: _PendingRequest, + subscription: NotificationSubscription, + ) -> None: + self._client = client + self._session_id = session_id + self._request = request + self._subscription = subscription + self._lock = threading.Lock() + self._closed = False + self._released = False + self._routed = False + self._received = False + self._message_id = "" + self._notifications: list[Notification] = [] + self._events: list[JsonObject] = [] + self._buffer: deque[Notification] = deque() + self._terminal: RunResult | CommandExecution | None = None + + def _release(self, error: BaseException | None = None) -> None: + with self._lock: + if self._released: + return + self._released = True + self._client._discard_request(self._request, error) + if error is None: + self._subscription.close() + else: + self._subscription._cancel(error) + + def close(self) -> None: + """Stop observing this query; the runtime operation is not cancelled.""" + with self._lock: + if self._closed: + return + self._closed = True + self._release(TransportClosedError("query response iterator closed")) + + def _ensure_open(self) -> None: + if self._closed: + raise StopIteration + + def __next__(self) -> Notification | RunResult | CommandExecution: + try: + self._ensure_open() + if not self._routed: + response = self._client._wait_request(self._request) + self._ensure_open() + self._routed = True + if not isinstance(response, dict): + raise SdkProtocolError("session/query response must be a JSON object") + if response.get("kind") == "command": + self._terminal = CommandExecution.model_validate(response.get("execution")) + self._release() + while True: + try: + notification = self._subscription._next_queued() + except TransportClosedError: + break + if notification is None: + break + self._buffer.append(notification) + if _is_command_done(notification, self._session_id, self._terminal.command_id): + break + elif response.get("kind") == "prompt" and isinstance(response.get("messageId"), str) and response["messageId"]: + self._message_id = response["messageId"] + else: + raise SdkProtocolError("session/query requires a prompt messageId or command execution result") + self._ensure_open() + if self._buffer: + return self._buffer.popleft() + if self._terminal is not None: + terminal = self._terminal + self._terminal = None + self._release() + self._closed = True + return terminal + while True: + notification = self._subscription.next() + self._ensure_open() + if not self._received: + if not _is_inbox_receipt(notification, self._session_id, self._message_id): + continue + self._received = True + self._notifications.append(notification) + if notification.method == "session.event" and notification.payload.get("sessionId") == self._session_id: + event = notification.payload.get("event") + if isinstance(event, dict): + self._events.append(event) + if (notification.method == "session.status" + and notification.payload.get("sessionId") == self._session_id + and notification.payload.get("status") == "idle"): + self._terminal = RunResult( + session_id=self._session_id, final_response=final_response(self._events), + finish_reason=finish_reason(self._events), events=self._events, notifications=self._notifications, + ) + self._release() + return notification + except BaseException as error: + locally_closed = self._closed + self.close() + if locally_closed and isinstance(error, (TransportClosedError, StopIteration)): + raise StopIteration + raise + + class DeepSeekHarness: """Reusable synchronous SDK for running DeepSeek Harness agent turns. @@ -148,6 +258,41 @@ class Session: def __init__(self, harness: DeepSeekHarness, session_id: str) -> None: self.harness = harness self.id = session_id + self._queries: deque[_QueryResponseIterator] = deque() + self._query_lock = threading.Lock() + + def query(self, text: str, *, delivery: Literal["queue", "steer"] = "queue") -> None: + """Send text for runtime prompt or command routing without waiting for the response. + + Pair each call with receive_response(). Delivery selects where ordinary + prompts enter the inbox; commands retain their native execution timing. + """ + if not isinstance(text, str): + raise TypeError("query text must be a string") + if delivery not in ("queue", "steer"): + raise ValueError("query delivery must be queue or steer") + self.harness.start() + client = self.harness.client + with self._query_lock: + subscription = client.subscribe_session_notifications(self.id) + try: + request = client._begin_request("session/query", {"sessionId": self.id, "text": text, "delivery": delivery}) + except BaseException: + subscription.close() + raise + self._queries.append(_QueryResponseIterator(client, self.id, request, subscription)) + + def receive_response(self) -> _QueryResponseIterator: + """Claim the next query in send order and iterate its notifications and terminal result. + + Prompts collect the inbox-receipt-to-idle interval used by run(); commands + finish on their RPC result. Close an unfinished iterator to release its + local observation resources without cancelling the runtime operation. + """ + with self._query_lock: + if not self._queries: + raise RuntimeError("receive_response requires a preceding query") + return self._queries.popleft() def get_working_directory(self) -> str: """Read the effective directory, recovering a missing directory to the Session origin.""" @@ -225,6 +370,16 @@ def _is_inbox_receipt(notification: Notification, session_id: str, message_id: s ) +def _is_command_done(notification: Notification, session_id: str, command_id: str) -> bool: + if notification.method != "session.event" or notification.payload.get("sessionId") != session_id: + return False + event = notification.payload.get("event") + if not isinstance(event, dict) or event.get("type") != "command/done": + return False + data = event.get("data") + return isinstance(data, dict) and data.get("commandId") == command_id + + def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]: if isinstance(input, str): return [{"type": "text", "text": input}] diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 2993bd25f1..1971077438 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -15,7 +15,7 @@ from typing import Callable, TypeAlias, TypeVar from pydantic import BaseModel from .errors import JsonRpcError, SdkProtocolError, TransportClosedError -from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest +from .models import CommandExecution, IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest ModelT = TypeVar("ModelT", bound=BaseModel) NotificationFilter: TypeAlias = Callable[[Notification], bool] @@ -39,6 +39,14 @@ class HarnessConfig: on_user_question: UserQuestionHandler | None = None +@dataclass(slots=True) +class _PendingRequest: + request_id: str + method: str + waiter: queue.Queue[JsonValue | BaseException] + deadline: float | None + + class HarnessClient: """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio.""" @@ -225,6 +233,14 @@ class HarnessClient: response_model=_EmptyResponse, ) + def session_command(self, session_id: str, line: str) -> CommandExecution: + """Execute an explicit command and return its handler result without waiting for a turn.""" + return self.request( + "session/command", + {"sessionId": session_id, "line": line}, + response_model=CommandExecution, + ) + def get_working_directory(self, session_id: str) -> str: """Read the effective directory, recovering a missing directory to the Session origin.""" response = self.request( @@ -324,15 +340,29 @@ class HarnessClient: notification_filter: NotificationFilter | None = None, notification_subscription: "NotificationSubscription | None" = None, ) -> JsonValue: - request_id = str(uuid.uuid4()) - waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1) temp_subscription: NotificationSubscription | None = None subscription = notification_subscription - with self._lock: - self._responses[request_id] = waiter if on_notification is not None and subscription is None: temp_subscription = self.subscribe_notifications(notification_filter) subscription = temp_subscription + try: + pending = self._begin_request(method, params, timeout_seconds=timeout_seconds) + return self._wait_request(pending, on_notification=on_notification, notification_subscription=subscription) + finally: + if temp_subscription is not None: + temp_subscription.close() + + def _begin_request( + self, + method: str, + params: JsonObject | None = None, + *, + timeout_seconds: float | None = None, + ) -> _PendingRequest: + request_id = str(uuid.uuid4()) + waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1) + with self._lock: + self._responses[request_id] = waiter try: message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method} if params is not None: @@ -341,48 +371,59 @@ class HarnessClient: except BaseException: with self._lock: self._responses.pop(request_id, None) - if temp_subscription is not None: - temp_subscription.close() raise timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds deadline = None if timeout is None else time.monotonic() + timeout + return _PendingRequest(request_id, method, waiter, deadline) + + def _discard_request(self, pending: _PendingRequest, error: BaseException | None = None) -> None: + with self._lock: + if self._responses.get(pending.request_id) is pending.waiter: + self._responses.pop(pending.request_id) + if error is not None: + pending.waiter.put_nowait(error) + + def _wait_request( + self, + pending: _PendingRequest, + *, + on_notification: Callable[[Notification], None] | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> JsonValue: + subscription = notification_subscription try: while True: if on_notification is not None and subscription is not None: subscription.drain(on_notification) + try: + item = pending.waiter.get_nowait() + break + except queue.Empty: + pass wait_timeout = None if on_notification is not None: wait_timeout = 0.05 - if deadline is not None: - remaining = deadline - time.monotonic() + if pending.deadline is not None: + remaining = pending.deadline - time.monotonic() if remaining <= 0: - with self._lock: - self._responses.pop(request_id, None) diagnostics = self._runtime_diagnostics() suffix = f"\n{diagnostics}" if diagnostics else "" raise TimeoutError( - f"{method} timed out waiting for DeepSeek Harness runtime{suffix}" + f"{pending.method} timed out waiting for DeepSeek Harness runtime{suffix}" ) wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining) try: - item = waiter.get(timeout=wait_timeout) - if on_notification is not None and subscription is not None: - subscription.drain(on_notification) + item = pending.waiter.get(timeout=wait_timeout) break except queue.Empty: continue - except BaseException: - with self._lock: - self._responses.pop(request_id, None) - if temp_subscription is not None: - temp_subscription.close() - raise + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) + if isinstance(item, BaseException): + raise item + return item finally: - if temp_subscription is not None: - temp_subscription.close() - if isinstance(item, BaseException): - raise item - return item + self._discard_request(pending) def _write_message(self, message: JsonObject) -> None: proc = self._proc @@ -675,12 +716,25 @@ class NotificationSubscription: self._closed = True self._client._unsubscribe_notifications(self._subscription_id) + def _cancel(self, error: BaseException) -> None: + self.close() + self._notifications.put(error) + def next(self) -> Notification: item = self._notifications.get() if isinstance(item, BaseException): raise item return item + def _next_queued(self) -> Notification | None: + try: + item = self._notifications.get_nowait() + except queue.Empty: + return None + if isinstance(item, BaseException): + raise item + return item + def drain(self, on_notification: Callable[[Notification], None]) -> None: while True: try: diff --git a/python/sdk/src/deepseek_harness/models.py b/python/sdk/src/deepseek_harness/models.py index 61020ad464..d4bfb72da5 100644 --- a/python/sdk/src/deepseek_harness/models.py +++ b/python/sdk/src/deepseek_harness/models.py @@ -1,9 +1,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal, TypeAlias +from typing import Annotated, Literal, TypeAlias -from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue +from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue, field_validator JsonScalar: TypeAlias = str | int | float | bool | None JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"] @@ -32,6 +32,45 @@ class InitializeResponse(BaseModel): serverInfo: ServerInfo | None = None +class CommandSuccessResult(BaseModel): + """Successful handler result, optionally linked to a Session event.""" + + kind: Literal["success"] + text: str | None = None + source_event_seq: int | None = Field(default=None, alias="sourceEventSeq", strict=True, ge=0, le=2**53 - 1) + + @field_validator("text", "source_event_seq", mode="before") + @classmethod + def _reject_null(cls, value: object) -> object: + if value is None: + raise ValueError("optional command result fields must be omitted instead of null") + return value + + +class CommandErrorResult(BaseModel): + """A command handler's reported failure.""" + + kind: Literal["error"] + text: str + + @field_validator("text") + @classmethod + def _non_blank_text(cls, value: str) -> str: + if not value.strip(): + raise ValueError("command error text must not be blank") + return value + + +CommandResult: TypeAlias = Annotated[CommandSuccessResult | CommandErrorResult, Field(discriminator="kind")] + + +class CommandExecution(BaseModel): + """Settled command result and the id pairing its command/run and command/done events.""" + + command_id: str = Field(alias="commandId", min_length=1) + result: CommandResult + + class ApprovalRequestParams(BaseModel): """Server-to-client approval/request payload after explicit handshake opt-in.""" diff --git a/python/sdk/tests/test_commands.py b/python/sdk/tests/test_commands.py new file mode 100644 index 0000000000..23c26527e8 --- /dev/null +++ b/python/sdk/tests/test_commands.py @@ -0,0 +1,139 @@ +"""Explicit command transport, results, and ordinary prompt compatibility.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from deepseek_harness import CommandErrorResult, CommandExecution, CommandSuccessResult, DeepSeekHarness +from deepseek_harness.errors import JsonRpcError + + +@pytest.fixture +def peer(tmp_path: Path) -> Path: + script = tmp_path / "commands.py" + script.write_text('''import json +import os +import sys + +def send(frame): + print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True) + +for line in sys.stdin: + frame = json.loads(line) + with open(os.environ["REQUEST_LOG"], "a", encoding="utf-8") as log: + log.write(line) + method = frame["method"] + params = frame.get("params") or {} + if method == "initialize": + send({"id": frame["id"], "result": {"serverInfo": {"name": "command-peer"}}}) + elif method == "session/command": + for event_type in ("command/run", "command/done"): + send({"method": "session.event", "params": {"sessionId": params["sessionId"], + "event": {"type": event_type, "data": {"commandId": "command-1"}}}}) + send({"id": frame["id"], **json.loads(os.environ["COMMAND_RESPONSE"])}) + elif method == "session/prompt": + session = params["sessionId"] + send({"id": frame["id"], "result": {"messageId": "input-1"}}) + events = [ + {"type": "agent/inbox/spliced", "data": {"inserted": [{"id": "input-1"}]}}, + {"type": "assistant/message", "data": {"message": {"content": [{"type": "text", "text": "ordinary prompt"}]}}}, + {"type": "turn/end", "data": {"turn": 1, "reason": {"kind": "completed"}}}, + ] + for event in events: + send({"method": "session.event", "params": {"sessionId": session, "event": event}}) + send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}}) + elif method == "shutdown": + send({"id": frame["id"], "result": {}}) + break + else: + raise AssertionError(method) +''', encoding="utf-8") + return script + + +def harness(peer: Path, tmp_path: Path, response: dict) -> DeepSeekHarness: + return DeepSeekHarness( + cwd=str(tmp_path), + dsh_home=str(tmp_path / "home"), + request_timeout_seconds=5, + _launch_args=(sys.executable, str(peer)), + env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"), "COMMAND_RESPONSE": json.dumps(response)}, + ) + + +def frames(tmp_path: Path) -> list[dict]: + return [json.loads(line) for line in (tmp_path / "requests.jsonl").read_text(encoding="utf-8").splitlines()] + + +@pytest.mark.parametrize("result", [ + {"kind": "success"}, + {"kind": "success", "text": "Compacted.", "sourceEventSeq": 0}, + {"kind": "success", "sourceEventSeq": 2**53 - 1}, + {"kind": "error", "text": "The agent is busy."}, + {"kind": "error", "text": " The agent is busy.\n"}, +]) +def test_command_returns_without_turn_or_idle_and_preserves_events(peer: Path, tmp_path: Path, result: dict) -> None: + response = {"commandId": "command-1", "result": result} + with harness(peer, tmp_path, {"result": response}) as sdk: + session = sdk.start_session("main") + with sdk.client.subscribe_session_notifications(session.id) as subscription: + execution = sdk.client.session_command(session.id, "/compact") + notifications = [] + subscription.drain(notifications.append) + assert isinstance(execution, CommandExecution) + assert execution.command_id == "command-1" + assert isinstance(execution.result, CommandSuccessResult if result["kind"] == "success" else CommandErrorResult) + assert execution.model_dump(by_alias=True, exclude_none=True) == response + assert [item.payload["event"]["type"] for item in notifications] == ["command/run", "command/done"] + sent = frames(tmp_path) + assert [frame["method"] for frame in sent] == ["initialize", "session/command", "shutdown"] + assert sent[1]["params"] == {"sessionId": "main", "line": "/compact"} + + +def test_low_level_command_preserves_the_line(peer: Path, tmp_path: Path) -> None: + with harness(peer, tmp_path, {"result": {"commandId": "command-1", "result": {"kind": "success"}}}) as sdk: + assert sdk.client.session_command("other", "/compact \t").result.kind == "success" + assert frames(tmp_path)[1]["params"] == {"sessionId": "other", "line": "/compact \t"} + + +def test_command_rpc_rejection_does_not_fall_back_to_prompt(peer: Path, tmp_path: Path) -> None: + with harness(peer, tmp_path, {"error": {"code": -32602, "message": "unsupported command"}}) as sdk: + with pytest.raises(JsonRpcError, match="unsupported command") as raised: + sdk.client.session_command("main", "/plan") + assert raised.value.code == -32602 + assert [frame["method"] for frame in frames(tmp_path)] == ["initialize", "session/command", "shutdown"] + + +@pytest.mark.parametrize("response", [ + {}, + {"commandId": "command-1"}, + {"commandId": "", "result": {"kind": "success"}}, + {"commandId": "command-1", "result": {"kind": "unknown"}}, + {"commandId": "command-1", "result": {"kind": "error"}}, + {"commandId": "command-1", "result": {"kind": "error", "text": ""}}, + {"commandId": "command-1", "result": {"kind": "error", "text": " \t\n"}}, + {"commandId": "command-1", "result": {"kind": "success", "text": None}}, + {"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": None}}, + {"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": "4"}}, + {"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": True}}, + {"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": -1}}, + {"commandId": "command-1", "result": {"kind": "success", "sourceEventSeq": 2**53}}, +]) +def test_command_rejects_malformed_results(peer: Path, tmp_path: Path, response: dict) -> None: + with harness(peer, tmp_path, {"result": response}) as sdk: + with pytest.raises(ValidationError): + sdk.client.session_command("main", "/compact") + + +def test_run_keeps_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None: + with harness(peer, tmp_path, {"result": {}}) as sdk: + result = sdk.start_session("main").run("/compact") + assert result.final_response == "ordinary prompt" + assert result.finish_reason == "completed" + sent = frames(tmp_path) + assert [frame["method"] for frame in sent] == ["initialize", "session/prompt", "shutdown"] + assert sent[1]["params"] == {"sessionId": "main", "contentBlocks": [{"type": "text", "text": "/compact"}]} diff --git a/python/sdk/tests/test_commands_runtime.py b/python/sdk/tests/test_commands_runtime.py new file mode 100644 index 0000000000..343e9f1e3d --- /dev/null +++ b/python/sdk/tests/test_commands_runtime.py @@ -0,0 +1,465 @@ +"""Exercise /compact through Python and the real sdk profile with a local model. + +The default carrier is the repository's built dsh CLI. Set +DSH_COMMANDS_TEST_RUNTIME=installed to run these same cases against the imported +SDK wheel and its bundled runtime, including from outside the repository. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing, contextmanager +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Callable, Iterator, TypeVar + +import pytest + +from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, Session + + +_WAIT_SECONDS = 30 +_SESSION_ID = "commands-runtime" +_MODEL = "deepseek-v4-flash" +_HISTORY_MARKER = "DSH_COMMANDS_OLD_USER_INPUT" +_FIRST_REPLY = "SDK_COMPACT_READY" +_CHECKPOINT = "## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task." +_CONTINUE_PROMPT = "DSH_COMMANDS_CONTINUE: answer using the retained checkpoint." +_CONTINUE_REPLY = "SDK_COMPACT_OK" +_T = TypeVar("_T") + + +@dataclass +class _Completion: + text: str + blocked: bool = False + entered: threading.Event = field(default_factory=threading.Event) + release: threading.Event = field(default_factory=threading.Event) + + def __post_init__(self) -> None: + if not self.blocked: + self.release.set() + + +def _messages_response(text: str, model: str, ordinal: int) -> bytes: + events = [ + {"type": "message_start", "message": { + "id": f"commands-response-{ordinal}", "type": "message", "role": "assistant", + "model": model, "content": [], "stop_reason": None, "stop_sequence": None, + "usage": {"input_tokens": 10000, "output_tokens": 0}, + }}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 32}}, + {"type": "message_stop"}, + ] + return "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events).encode() + + +class _ModelServer: + def __init__(self, completions: list[_Completion]) -> None: + self.completions = completions + self.requests: list[dict] = [] + self.failures: list[BaseException] = [] + self.lock = threading.Lock() + owner = self + + class Handler(BaseHTTPRequestHandler): + def setup(self) -> None: + self.request.settimeout(_WAIT_SECONDS) + super().setup() + + def do_POST(self) -> None: + assert self.path == "/v1/messages", f"unexpected model endpoint: {self.path}" + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + assert isinstance(body, dict) and isinstance(body.get("model"), str) + with owner.lock: + ordinal = len(owner.requests) + owner.requests.append(body) + assert ordinal < len(owner.completions), "unexpected additional model request" + completion = owner.completions[ordinal] + completion.entered.set() + # The test owns release. This larger bound only contains a failed test. + assert completion.release.wait(_WAIT_SECONDS * 2), "model barrier was not released" + payload = _messages_response(completion.text, body["model"], ordinal) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + self.wfile.flush() + + def log_message(self, _format: str, *_args: object) -> None: + pass + + class Server(ThreadingHTTPServer): + # server_close joins request handlers after their barriers are released. + daemon_threads = False + + def handle_error(self, _request: object, _client_address: object) -> None: + error = sys.exc_info()[1] + assert error is not None + with owner.lock: + owner.failures.append(error) + + self.server = Server(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, name="commands-model", daemon=True) + host, port = self.server.server_address + self.url = f"http://{host}:{port}" + + def __enter__(self) -> _ModelServer: + try: + self.thread.start() + except BaseException: + self.server.server_close() + raise + return self + + def release_all(self) -> None: + for completion in self.completions: + completion.release.set() + + def captured(self) -> list[dict]: + with self.lock: + return list(self.requests) + + def __exit__(self, exc_type: object, _exc: object, _tb: object) -> None: + self.release_all() + self.server.shutdown() + try: + self.server.server_close() + finally: + self.thread.join(_WAIT_SECONDS) + assert not self.thread.is_alive(), "model listener did not stop" + if exc_type is None: + assert not self.failures, [repr(error) for error in self.failures] + + +def _dsh_bin() -> str | None: + mode = os.environ.get("DSH_COMMANDS_TEST_RUNTIME", "built") + if mode == "installed": + return None + if mode != "built": + raise ValueError("DSH_COMMANDS_TEST_RUNTIME must be built or installed") + repo = Path(__file__).resolve().parents[3] + node = shutil.which("node") + binary = repo / "apps/cli/lib/bin.js" + if node is None or not binary.is_file(): + pytest.skip("requires Node and built dsh CLI, or DSH_COMMANDS_TEST_RUNTIME=installed") + return str(binary) + + +@dataclass +class _Runtime: + harness: DeepSeekHarness + model: _ModelServer + workers: ThreadPoolExecutor + logs: Path + + def call(self, operation: Callable[..., _T], *args: object, **kwargs: object) -> _T: + return self.workers.submit(operation, *args, **kwargs).result(timeout=_WAIT_SECONDS) + + +@contextmanager +def _runtime( + tmp_path: Path, completions: list[_Completion], *, capture_patch: Path | None = None, +) -> Iterator[_Runtime]: + workspace = tmp_path / "workspace" + workspace.mkdir() + home = tmp_path / "home" + logs = tmp_path / "sessions" + patch = tmp_path / "commands.patch.yml" + # Raw JSONL permits an independent read at the compaction durability boundary. + patch.write_text( + "- id: session-persistence-jsonl\n config:\n root: " + json.dumps(str(logs)) + + "\n compression: none\n", + encoding="utf-8", + ) + patches = (patch,) if capture_patch is None else (patch, capture_patch.resolve()) + dsh_bin = _dsh_bin() + with _ModelServer(completions) as model, ThreadPoolExecutor(max_workers=2) as workers: + harness = DeepSeekHarness( + cwd=str(workspace), runtime_cwd=str(workspace), dsh_home=str(home), + dsh_bin=dsh_bin, profile="sdk", patches=tuple(str(item) for item in patches), model=_MODEL, + base_url=model.url, api_key="keyless-commands-runtime", + initialize_timeout_seconds=60, request_timeout_seconds=20, shutdown_timeout_seconds=20, + env={"DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", "DSH_TOOLS_MODE": "native"}, + ) + try: + harness.start() + yield _Runtime(harness, model, workers, logs) + finally: + # Also covers failed initialization: release HTTP, close the client, then join callers. + model.release_all() + harness.close() + + +def _read_events(root: Path, *, live: bool = False) -> list[dict]: + paths = list(root.rglob("session*.jsonl")) + assert len(paths) == 1, f"expected one fresh Session log, found {paths}" + content = paths[0].read_bytes() + if not live: + assert content.endswith(b"\n"), "closed Session log has an incomplete final row" + # A live writer may append an unflushed suffix after the checkpoint we inspect. + complete = content[:content.rfind(b"\n") + 1] + records = [json.loads(line) for line in complete.splitlines()] + assert records and records[0]["type"] == "session" + assert records[0]["id"] == _SESSION_ID + events = records[1:] + for seq, event in enumerate(events): + assert isinstance(event, dict) + assert event.setdefault("seq", seq) == seq + return events + + +def _of_type(events: list[dict], name: str) -> list[dict]: + return [event for event in events if event["type"] == name] + + +def _command_pair(events: list[dict], execution: CommandExecution) -> None: + runs = [event for event in _of_type(events, "command/run") + if event["data"]["commandId"] == execution.command_id] + done = [event for event in _of_type(events, "command/done") + if event["data"]["commandId"] == execution.command_id] + assert len(runs) == len(done) == 1 + assert runs[0]["data"]["name"] == "compact" + assert runs[0]["seq"] < done[0]["seq"] + assert done[0]["data"]["kind"] == execution.result.kind + assert done[0]["data"].get("text") == execution.result.text + + +def _text_blocks(content: object) -> list[str]: + if isinstance(content, str): + return [content] + assert isinstance(content, list) + return [block["text"] for block in content if block.get("type") == "text"] + + +def _request_texts(request: dict) -> list[str]: + return [text for message in request["messages"] for text in _text_blocks(message["content"])] + + +def _receive_result( + session: Session, response_claimed: threading.Event | None = None, +) -> RunResult | CommandExecution: + result: RunResult | CommandExecution | None = None + with closing(session.receive_response()) as responses: + if response_claimed is not None: + response_claimed.set() + for item in responses: + assert result is None, "receive_response yielded another item after its terminal result" + if not isinstance(item, Notification): + assert isinstance(item, (RunResult, CommandExecution)), type(item) + result = item + assert result is not None, "receive_response ended without a terminal result" + return result + + +def _wait_command_done(next_notification: Callable[[], Notification]) -> dict: + while True: + notification = next_notification() + if notification.method != "session.event" or notification.payload.get("sessionId") != _SESSION_ID: + continue + event = notification.payload.get("event") + if isinstance(event, dict) and event.get("type") == "command/done": + return event["data"] + + +def test_compact_without_history_returns_without_a_model_turn(tmp_path: Path) -> None: + with _runtime(tmp_path, []) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + observer = runtime.harness.client.subscribe_session_notifications(_SESSION_ID) + try: + assert runtime.call(session.query, " \n/压缩\t ", delivery="queue") is None + # Wait independently for completion before starting the query's receiver. + done = runtime.call(_wait_command_done, observer.next) + execution = runtime.call(_receive_result, session) + except BaseException: + # Wake a timed-out observer before unregistering its otherwise blocking queue. + runtime.harness.close() + raise + finally: + observer.close() + assert isinstance(execution, CommandExecution) + assert done["commandId"] == execution.command_id + assert execution.result.kind == "success" + assert execution.result.source_event_seq is None + assert runtime.model.captured() == [] + events = _read_events(runtime.logs) + _command_pair(events, execution) + assert not {event["type"] for event in events} & { + "turn/start", "step/start", "request/header", "compaction/start", "user/message", + } + + +def test_compact_rejects_busy_agent_before_the_model_is_released(tmp_path: Path) -> None: + response = _Completion("DSH_COMMANDS_BUSY_REPLY", blocked=True) + with _runtime(tmp_path, [response]) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + sending = runtime.workers.submit( + session.query, "Hold this request while compaction is attempted.", delivery="queue", + ) + assert response.entered.wait(_WAIT_SECONDS), runtime.model.failures + assert sending.result(timeout=_WAIT_SECONDS) is None + assert not response.release.is_set(), "query waited for the model before returning" + + # Claim the prompt's FIFO slot before issuing the command for another receiver. + receiving = threading.Event() + running = runtime.workers.submit(_receive_result, session, receiving) + assert receiving.wait(_WAIT_SECONDS), runtime.model.failures + assert not running.done() + assert runtime.call(session.query, "/compact", delivery="queue") is None + execution = runtime.call(_receive_result, session) + assert isinstance(execution, CommandExecution) + assert execution.result.kind == "error" + assert not response.release.is_set() + assert not running.done() + assert len(runtime.model.captured()) == 1 + response.release.set() + completed = running.result(timeout=_WAIT_SECONDS) + assert isinstance(completed, RunResult) + assert completed.final_response == response.text + events = _read_events(runtime.logs) + _command_pair(events, execution) + assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 1 + assert not _of_type(events, "compaction/start") + + +def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None) -> Path: + """Run the complete scenario and retain its real Session log under the caller's directory.""" + summary_response = _Completion(_CHECKPOINT, blocked=True) + with _runtime( + tmp_path, [_Completion(_FIRST_REPLY), summary_response, _Completion(_CONTINUE_REPLY)], + capture_patch=capture_patch, + ) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + # The replaced span is deliberately larger than the framed summary. + prompt = _HISTORY_MARKER + "\n" + "Keep the original request only in the stored history. " * 256 + assert runtime.call(session.query, prompt, delivery="queue") is None + first_result = runtime.call(_receive_result, session) + assert isinstance(first_result, RunResult) + assert first_result.final_response == _FIRST_REPLY + + sending = runtime.workers.submit(session.query, "/compact", delivery="queue") + assert summary_response.entered.wait(_WAIT_SECONDS), runtime.model.failures + assert sending.result(timeout=_WAIT_SECONDS) is None + assert not summary_response.release.is_set(), "query waited for the summary before returning" + receiving = threading.Event() + compacting = runtime.workers.submit(_receive_result, session, receiving) + assert receiving.wait(_WAIT_SECONDS), runtime.model.failures + assert not compacting.done(), "command returned before its summary request completed" + summary_response.release.set() + execution = compacting.result(timeout=_WAIT_SECONDS) + assert isinstance(execution, CommandExecution) + assert execution.result.kind == "success" + assert execution.result.source_event_seq is not None + + # The native compactor promises this prefix before returning, unlike command/done. + checkpointed = _read_events(runtime.logs, live=True) + summaries = _of_type(checkpointed, "compaction/summary") + starts = _of_type(checkpointed, "compaction/start") + ends = _of_type(checkpointed, "compaction/end") + assert len(starts) == len(summaries) == len(ends) == 1 + summary = summaries[0] + assert summary["seq"] == execution.result.source_event_seq + assert summary["data"]["llmStreamCall"] is True + assert summary["data"]["provider"] == "deepseek-official" + assert summary["data"]["model"] == _MODEL + for event in [starts[0], summary, ends[0]]: + assert event["data"]["sourceCommandId"] == execution.command_id + assert event["data"]["compactionId"] == summary["data"]["compactionId"] + assert starts[0]["data"]["turn"] is None and ends[0]["data"]["turn"] is None + assert "error" not in ends[0]["data"] + checkpoint = checkpointed[summary["seq"] + 1] + assert checkpoint["type"] == "user/message" + assert checkpoint["data"]["source"]["kind"] == "compact-checkpoint" + assert checkpoint["surfaceOp"]["op"] == "replace" + checkpoint_text = "\n".join(_text_blocks(checkpoint["data"]["content"])) + assert _CHECKPOINT in checkpoint_text + assert "" in checkpoint_text and "" in checkpoint_text + assert len(_of_type(checkpointed, "turn/start")) == len(_of_type(checkpointed, "turn/end")) == 1 + + assert runtime.call(session.query, _CONTINUE_PROMPT, delivery="queue") is None + continued = runtime.call(_receive_result, session) + assert isinstance(continued, RunResult) + assert continued.final_response == _CONTINUE_REPLY + first, compaction, following = runtime.model.captured() + assert first["system"] == compaction["system"] + assert first["tools"] == compaction["tools"] + initial_texts = _request_texts(first) + summary_texts = _request_texts(compaction) + assert summary_texts[:len(initial_texts)] == initial_texts + assert "You are now acting as a compaction engine" in summary_texts[-1] + following_texts = _request_texts(following) + following_text = "\n".join(following_texts) + assert _CHECKPOINT in following_text and _CONTINUE_PROMPT in following_text + assert _FIRST_REPLY in following_text + assert _HISTORY_MARKER not in following_text + assert "/compact" not in following_texts + assert execution.result.text not in following_text + + events = _read_events(runtime.logs) + _command_pair(events, execution) + assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2 + assert len(_of_type(events, "step/start")) == len(_of_type(events, "step/end")) == 2 + assert _HISTORY_MARKER in json.dumps(events), "compaction erased the original stored input" + return next(runtime.logs.rglob("session*.jsonl")) + + +def test_compact_checkpoints_history_before_continuing_the_same_session(tmp_path: Path) -> None: + run_compaction_scenario(tmp_path) + + +def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_path: Path) -> None: + prompts = ["/sdk-unregistered-command-fixture", "/compact extra"] + replies = ["SDK_UNKNOWN_SLASH_REPLY", "SDK_COMPACT_ARGUMENTS_REPLY"] + with _runtime(tmp_path, [_Completion(text) for text in replies]) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + for prompt, reply in zip(prompts, replies): + assert runtime.call(session.query, prompt, delivery="queue") is None + result = runtime.call(_receive_result, session) + assert isinstance(result, RunResult) + assert result.final_response == reply + requests = runtime.model.captured() + assert len(requests) == len(prompts) + for prompt, request in zip(prompts, requests): + assert prompt in _request_texts(request) + events = _read_events(runtime.logs) + assert not _of_type(events, "command/run") + assert not _of_type(events, "compaction/start") + assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2 + + +def test_unimplemented_slash_remains_plain_text_without_changing_plan_mode(tmp_path: Path) -> None: + plan_text = "/plan DSH_PLAN_TEXT_INPUT" + following = "DSH_AFTER_PLAN_TEXT_INPUT" + plan_reply = "DSH_PLAN_TEXT_REPLY" + reply = "DSH_AFTER_PLAN_TEXT_REPLY" + with _runtime(tmp_path, [_Completion(plan_reply), _Completion(reply)]) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + assert runtime.call(session.query, plan_text, delivery="queue") is None + first = runtime.call(_receive_result, session) + assert isinstance(first, RunResult) + assert first.final_response == plan_reply + + assert runtime.call(session.query, following, delivery="queue") is None + result = runtime.call(_receive_result, session) + assert isinstance(result, RunResult) + assert result.final_response == reply + initial, continued = runtime.model.captured() + assert plan_text in _request_texts(initial) + assert following in _request_texts(continued) + assert initial["tools"] == continued["tools"] + events = _read_events(runtime.logs) + assert not _of_type(events, "command/run") + assert not any(event["data"].get("active") is True for event in _of_type(events, "plan/mode")) + assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2 diff --git a/python/sdk/tests/test_queries.py b/python/sdk/tests/test_queries.py new file mode 100644 index 0000000000..7ae561ed4e --- /dev/null +++ b/python/sdk/tests/test_queries.py @@ -0,0 +1,391 @@ +"""Split query submission and per-request response observation.""" +from __future__ import annotations + +import sys +import threading +from pathlib import Path + +import pytest +from pydantic import BaseModel, ValidationError + +from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, SdkProtocolError +from deepseek_harness.errors import JsonRpcError, TransportClosedError + + +class Ack(BaseModel): + pass + + +@pytest.fixture +def peer(tmp_path: Path) -> Path: + script = tmp_path / "queries.py" + script.write_text('''import json +import sys + +serial = 0 +pending_commands = [] +pending_prompts = [] + +def send(frame): + print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True) + +def event(session, type, data): + send({"method": "session.event", "params": {"sessionId": session, "event": {"type": type, "data": data}}}) + +def command(frame, number): + session = frame["params"]["sessionId"] + identity = "command-" + str(number) + event(session, "command/run", {"commandId": identity}) + event(session, "command/done", {"commandId": identity, "kind": "success"}) + send({"id": frame["id"], "result": {"kind": "command", "execution": { + "commandId": identity, "result": {"kind": "success", "text": "compacted"}}}}) + +def receipt(frame, number): + session = frame["params"]["sessionId"] + send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}}) + event(session, "agent/inbox/spliced", {"inserted": [{"id": "input-" + str(number)}]}) + +def finish(frame): + session = frame["params"]["sessionId"] + event(session, "assistant/message", {"message": {"content": [ + {"type": "text", "text": "answer for " + frame["params"]["text"]}]}}) + event(session, "turn/end", {"turn": 1, "reason": {"kind": "completed"}}) + send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}}) + +for line in sys.stdin: + frame = json.loads(line) + method = frame["method"] + if method == "initialize": + send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}}) + elif method == "session/query": + serial += 1 + params = frame["params"] + assert set(params) == {"sessionId", "text", "delivery"} + assert params["delivery"] in ("queue", "steer") + text = params["text"] + if text == "hold-command": + pending_commands.append((frame, serial)) + elif text == "/compact": + command(frame, serial) + elif text == "silent-command": + send({"id": frame["id"], "result": {"kind": "command", "execution": { + "commandId": "silent", "result": {"kind": "success"}}}}) + elif text == "rpc-error": + send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}}) + elif text == "bad-route": + send({"id": frame["id"], "result": {"kind": "unknown"}}) + elif text == "bad-execution": + send({"id": frame["id"], "result": {"kind": "command", "execution": {"commandId": "c"}}}) + else: + result = {"kind": "prompt", "messageId": "input-" + str(serial)} + if text == "fast-prompt": + receipt(frame, serial) + finish(frame) + send({"id": frame["id"], "result": result}) + else: + send({"id": frame["id"], "result": result}) + receipt(frame, serial) + if text == "hold-prompt": + pending_prompts.append(frame) + else: + finish(frame) + elif method == "release": + for original, number in pending_commands: + command(original, number) + pending_commands.clear() + for original in pending_prompts: + finish(original) + pending_prompts.clear() + send({"id": frame["id"], "result": {}}) + elif method == "barrier": + send({"id": frame["id"], "result": {}}) + elif method == "disconnect": + send({"id": frame["id"], "result": {}}) + break + elif method == "shutdown": + send({"id": frame["id"], "result": {}}) + break + else: + raise AssertionError(method) +''', encoding="utf-8") + return script + + +@pytest.fixture +def sdk(peer: Path, tmp_path: Path): + with DeepSeekHarness( + cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5, + _launch_args=(sys.executable, str(peer)), + ) as harness: + yield harness + + +def release(sdk: DeepSeekHarness) -> None: + sdk.client.request("release", {}, response_model=Ack, timeout_seconds=5) + + +def assert_released(sdk: DeepSeekHarness) -> None: + assert sdk.client._responses == {} + assert sdk.client._notification_subscribers == {} + + +def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + assert session.query("hold-command") is None + assert len(sdk.client._responses) == 1 + release(sdk) + responses = list(session.receive_response()) + assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"] + assert isinstance(responses[-1], CommandExecution) + assert responses[-1].command_id == "command-1" + assert_released(sdk) + + +@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/plan"]) +@pytest.mark.parametrize("delivery", ["queue", "steer"]) +def test_prompt_yields_receipt_through_idle_then_run_result(sdk: DeepSeekHarness, text: str, delivery: str) -> None: + session = sdk.start_session("main") + session.query(text, delivery=delivery) + responses = list(session.receive_response()) + assert all(isinstance(item, Notification) for item in responses[:-1]) + assert responses[0].payload["event"]["type"] == "agent/inbox/spliced" + result = responses[-1] + assert isinstance(result, RunResult) + assert result.final_response == "answer for " + text + assert result.finish_reason == "completed" + assert result.notifications == responses[:-1] + assert len(result.events) == 3 + assert_released(sdk) + + +def test_late_receive_uses_an_arrived_response_before_expired_deadline(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + sdk.client.config.request_timeout_seconds = 0 + session.query("/compact") + sdk.client.request("barrier", {}, response_model=Ack, timeout_seconds=5) + assert isinstance(list(session.receive_response())[-1], CommandExecution) + assert_released(sdk) + + +def test_pending_response_timeout_releases_only_its_observer(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + sdk.client.config.request_timeout_seconds = 0 + session.query("hold-command") + with pytest.raises(TimeoutError, match="session/query timed out"): + list(session.receive_response()) + assert_released(sdk) + release(sdk) + + +@pytest.mark.parametrize("text,error", [ + ("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError), +]) +def test_query_errors_arrive_during_receive_and_leave_later_queries_usable(sdk: DeepSeekHarness, text: str, error: type) -> None: + session = sdk.start_session("main") + session.query(text) + with pytest.raises(error): + list(session.receive_response()) + assert_released(sdk) + session.query("/compact") + assert isinstance(list(session.receive_response())[-1], CommandExecution) + + +def test_receivers_claim_fifo_even_when_responses_arrive_out_of_order(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("hold-command") + session.query("/compact") + release(sdk) + first = session.receive_response() + second = session.receive_response() + assert list(second)[-1].command_id == "command-2" + assert list(first)[-1].command_id == "command-1" + assert_released(sdk) + with pytest.raises(RuntimeError, match="preceding query"): + session.receive_response() + + +def test_command_receiver_stops_at_its_done_before_a_later_prompt(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("/compact") + session.query("ordinary") + sdk.client.request("barrier", {}, response_model=Ack) + command = list(session.receive_response()) + assert [item.payload["event"]["type"] for item in command[:-1]] == ["command/run", "command/done"] + assert command[-1].command_id == "command-1" + prompt = list(session.receive_response()) + assert isinstance(prompt[-1], RunResult) + assert prompt[-1].final_response == "answer for ordinary" + assert_released(sdk) + + +def test_command_result_without_notifications_does_not_wait_for_done(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("silent-command") + responses = list(session.receive_response()) + assert len(responses) == 1 + assert responses[0].command_id == "silent" + assert_released(sdk) + + +@pytest.mark.parametrize("text", ["/compact", "silent-command"]) +def test_late_command_receive_preserves_completed_result_after_eof(sdk: DeepSeekHarness, text: str) -> None: + session = sdk.start_session("main") + session.query(text) + sdk.client.request("disconnect", {}, response_model=Ack) + sdk.client._reader_thread.join(5) + assert not sdk.client._reader_thread.is_alive() + responses = list(session.receive_response()) + assert isinstance(responses[-1], CommandExecution) + assert responses[-1].result.kind == "success" + if text == "/compact": + assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"] + else: + assert len(responses) == 1 + assert_released(sdk) + + +def test_command_receive_does_not_hide_other_subscription_errors(sdk: DeepSeekHarness, monkeypatch) -> None: + session = sdk.start_session("main") + session.query("/compact") + sdk.client.request("barrier", {}, response_model=Ack) + receiver = session.receive_response() + + def fail(): + raise SdkProtocolError("invalid notification") + + monkeypatch.setattr(receiver._subscription, "_next_queued", fail) + with pytest.raises(SdkProtocolError, match="invalid notification"): + list(receiver) + assert_released(sdk) + + +def test_query_and_another_receiver_can_run_while_a_prompt_receiver_waits(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("hold-prompt") + receiver = session.receive_response() + entered = threading.Event() + responses = [] + errors = [] + + def receive() -> None: + try: + for item in receiver: + responses.append(item) + entered.set() + except BaseException as error: + errors.append(error) + + thread = threading.Thread(target=receive) + thread.start() + try: + assert entered.wait(5) + session.query("/compact") + assert list(session.receive_response())[-1].command_id == "command-2" + assert thread.is_alive() + release(sdk) + thread.join(5) + assert not thread.is_alive() + assert not errors + assert isinstance(responses[-1], RunResult) + assert responses[-1].final_response == "answer for hold-prompt" + assert_released(sdk) + finally: + receiver.close() + thread.join(5) + + +def test_iterator_close_before_first_next_releases_request_and_subscription(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("hold-command") + receiver = session.receive_response() + receiver.close() + receiver.close() + assert list(receiver) == [] + assert_released(sdk) + release(sdk) + session.query("/compact") + assert list(session.receive_response())[-1].command_id == "command-2" + + +def test_iterator_close_after_a_prompt_notification_releases_its_subscription(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("hold-prompt") + receiver = session.receive_response() + assert isinstance(next(receiver), Notification) + receiver.close() + assert list(receiver) == [] + assert_released(sdk) + release(sdk) + + +def test_iterator_close_wakes_its_blocked_request_receiver(sdk: DeepSeekHarness, monkeypatch) -> None: + session = sdk.start_session("main") + session.query("hold-command") + receiver = session.receive_response() + entered = threading.Event() + errors = [] + original = sdk.client._wait_request + + def wait(request, **kwargs): + entered.set() + return original(request, **kwargs) + + monkeypatch.setattr(sdk.client, "_wait_request", wait) + + def receive() -> None: + try: + assert list(receiver) == [] + except BaseException as error: + errors.append(error) + + thread = threading.Thread(target=receive) + thread.start() + try: + assert entered.wait(5) + receiver.close() + thread.join(5) + assert not thread.is_alive() + assert not errors + assert_released(sdk) + finally: + receiver.close() + thread.join(5) + + +def test_terminal_result_releases_resources_before_caller_sends_another_query(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("/compact") + receiver = session.receive_response() + for item in receiver: + if isinstance(item, CommandExecution): + assert_released(sdk) + session.query("/compact") + receiver.close() + assert list(session.receive_response())[-1].command_id == "command-2" + assert_released(sdk) + + +def test_runtime_close_settles_unreceived_queries(sdk: DeepSeekHarness) -> None: + session = sdk.start_session("main") + session.query("hold-command") + sdk.close() + with pytest.raises(TransportClosedError): + list(session.receive_response()) + assert_released(sdk) + + +def test_send_failure_releases_subscription_without_queueing_a_receiver(sdk: DeepSeekHarness, monkeypatch) -> None: + session = sdk.start_session("main") + original = sdk.client._write_message + + def write(message): + if message.get("method") == "session/query": + raise TransportClosedError("write failed") + original(message) + + monkeypatch.setattr(sdk.client, "_write_message", write) + with pytest.raises(TransportClosedError, match="write failed"): + session.query("ordinary") + assert_released(sdk) + with pytest.raises(RuntimeError, match="preceding query"): + session.receive_response() diff --git a/snapshots/sdk/manual-compact/capture-requests.mjs b/snapshots/sdk/manual-compact/capture-requests.mjs new file mode 100644 index 0000000000..e9e9b267cc --- /dev/null +++ b/snapshots/sdk/manual-compact/capture-requests.mjs @@ -0,0 +1,21 @@ +/** Capture the isolated scenario's model inputs before adapter execution. */ +import { appendFileSync } from 'node:fs' + +/** + * @param ctx - Scenario context providing the LLM event stream. + * @param config - Output path under the scenario's private DSH home. + */ +export function apply(ctx, config) { + ctx.on('llm/stream', async function* (options, next) { + appendFileSync(config.path, JSON.stringify({ + provider: options.provider, + model: options.model, + purpose: options.purpose, + maxTokens: options.maxTokens, + reasoningEffort: options.reasoningEffort, + messages: options.messages, + tools: options.tools, + }) + '\n') + yield* next() + }) +} diff --git a/snapshots/sdk/manual-compact/capture.cordis.yml b/snapshots/sdk/manual-compact/capture.cordis.yml new file mode 100644 index 0000000000..7b0ed28891 --- /dev/null +++ b/snapshots/sdk/manual-compact/capture.cordis.yml @@ -0,0 +1,5 @@ +- insert: + - id: compact-request-capture + name: './capture-requests.mjs' + config: + path: !!js dshHomePath('compact-requests.jsonl') diff --git a/snapshots/sdk/manual-compact/session.v4.jsonl b/snapshots/sdk/manual-compact/session.v4.jsonl new file mode 100644 index 0000000000..7c0db5f61f --- /dev/null +++ b/snapshots/sdk/manual-compact/session.v4.jsonl @@ -0,0 +1,37 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1792108800000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"DSH_COMMANDS_OLD_USER_INPUT\nKeep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. "}],"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":"DSH_COMMANDS_OLD_USER_INPUT\nKeep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. Keep the original request only in the stored history. "}],"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}],"source":{"kind":"time-context","form":"snapshot","sections":[{"name":"time-context","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}]},"role":"user","id":"{{message:4}}"},"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":"DSH_COMMANDS_OLD_USER_INPUT Keep the ori","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":13}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_COMPACT_READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:5}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_COMPACT_READY"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_COMPACT_READY"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"command/run","data":{"commandId":"{{command:1}}","name":"compact","args":"","source":{"kind":"user"}}} +{"type":"compaction/start","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","turn":null}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":19}} +{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","summary":[{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."}],"rawOutput":[{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":10},"shadowedSeqs":[8,9,10],"shadowedTokenCount":3714,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":65536,"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"## Primary Request and Intent\n- DSH_COMMANDS_CHECKPOINT: retain the earlier task."},{"type":"text","text":""}],"source":{"kind":"compact-checkpoint","compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}"},"role":"user","id":"{{message:6}}"},"sourceEventSeqs":[19,21,8,9,10],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":10}} +{"type":"compaction/end","data":{"compactionId":"{{id:1}}","sourceCommandId":"{{command:1}}","turn":null}} +{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Compacted 3 history items (~3714 tokens).","sourceEventSeq":21}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"DSH_COMMANDS_CONTINUE: answer using the retained checkpoint."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]}} +{"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":"DSH_COMMANDS_CONTINUE: answer using the retained checkpoint."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"},"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":31}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_COMPACT_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:9}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_COMPACT_OK"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_COMPACT_OK"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/manual-compact/snapshot.yml b/snapshots/sdk/manual-compact/snapshot.yml new file mode 100644 index 0000000000..d3b3e5a2a0 --- /dev/null +++ b/snapshots/sdk/manual-compact/snapshot.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: manual-compact +profile: sdk +composition: sdk-desktop +# The full SDK runtime captures the Session against a controlled model endpoint. +recording: authored +header: + class: sdk-desktop diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 2f5bbb00b5..793299faf8 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -1,7 +1,7 @@ /** * Keyless snapshot coverage for the TypeScript SDK path: each scenario spawns * the real `dsh --profile sdk` or `sdk-minimal` runtime through - * `@deepseek-ai/dsh-sdk-client`, drives one turn over stdio JSON-RPC, + * `@deepseek-ai/dsh-sdk-client`, drives recorded turns and standalone commands over stdio JSON-RPC, * and pins the SDK `RunResult`, the complete notification stream, and the * persisted session logs. Replay serves recorded model * responses via `llm-replay` (`cordis.snapshot.yml`); `DSH_SNAPSHOT=record` @@ -57,6 +57,7 @@ import { } from '@deepseek-ai/dsh-session-snapshot' import { DeepSeekHarness, + type CommandExecution, type HarnessNotification, type NotificationSubscription, type RunResult, @@ -144,6 +145,10 @@ const SDK_ASSERTIONS: Readonly> = { }, }, 'desktop-ptc-context': { expectedFinalResponse: 'SDK snapshot OK' }, + 'manual-compact': { + expectedFinalResponse: 'SDK_COMPACT_OK', + patches: [fileURLToPath(new URL('./manual-compact/capture.cordis.yml', import.meta.url))], + }, 'minimal-image-compaction': { expectedFinalResponse: 'MINIMAL_IMAGE_OK', expectedTools: { bash: ['command'], read_image: ['file_path'], working_directory: [] }, @@ -445,10 +450,18 @@ interface JsonObject { } interface TurnAction { + readonly kind: 'turn' readonly turn: number readonly content?: JsonObject[] } +interface CommandAction { + readonly kind: 'command' + readonly line: string +} + +type SdkAction = TurnAction | CommandAction + function records(log: string): JsonObject[] { return log.split(/\r?\n/) .filter(line => line.trim() !== '') @@ -479,21 +492,31 @@ function modelFromSession(log: string): { provider: string; model: string } { throw new Error('SDK snapshot session has no request model') } -function turnActions(log: string): TurnAction[] { - const actions: TurnAction[] = [] +/** Replay caller inputs from the log; in-turn commands need an explicit timing-aware controller. */ +function sdkActions(log: string): SdkAction[] { + const actions: SdkAction[] = [] let current: TurnAction | undefined for (const record of records(log)) { + if (record.type === 'command/run') { + if (current !== undefined) throw new Error('SDK snapshot cannot replay a command inside an open turn') + const data = record.data as JsonObject | undefined + if (typeof data?.name !== 'string' || typeof data.args !== 'string') { + throw new Error('SDK snapshot command/run requires a name and recorded string arguments') + } + actions.push({ kind: 'command', line: `/${data.name}${data.args}` }) + continue + } if (record.type === 'turn/start') { const data = record.data as JsonObject | undefined if (typeof data?.turn !== 'number') throw new Error('SDK snapshot turn/start has no turn') - current = { turn: data.turn } + current = { kind: 'turn', turn: data.turn } continue } if (record.type === 'user/message' && current !== undefined && current.content === undefined) { const data = record.data as JsonObject | undefined const source = data?.source as JsonObject | undefined if (source?.kind === 'user' && Array.isArray(data?.content)) { - current = { turn: current.turn, content: data.content as JsonObject[] } + current = { kind: 'turn', turn: current.turn, content: data.content as JsonObject[] } } continue } @@ -505,10 +528,10 @@ function turnActions(log: string): TurnAction[] { return actions } -function postTurnEventTypes(log: string): string[] { +function postActionEventTypes(log: string): string[] { const values = records(log) - const finalTurnEnd = values.findLastIndex(record => record.type === 'turn/end') - return values.slice(finalTurnEnd + 1).flatMap(record => typeof record.type === 'string' ? [record.type] : []) + const finalActionEnd = values.findLastIndex(record => record.type === 'turn/end' || record.type === 'command/done') + return values.slice(finalActionEnd + 1).flatMap(record => typeof record.type === 'string' ? [record.type] : []) } function materializeInput( @@ -565,6 +588,31 @@ async function waitForRootEvent( } } +/** Command lifecycle notifications precede the RPC result on the same ordered transport. */ +function collectCommandNotifications( + subscription: NotificationSubscription, + sessionId: string, + execution: CommandExecution, + notifications: HarnessNotification[], + observe: (notification: HarnessNotification) => void, +): void { + let started = false + for (let notification = subscription.tryNext(); notification !== undefined; notification = subscription.tryNext()) { + observe(notification) + const event = notification.params.sessionId === sessionId ? notificationEvent(notification) : undefined + const data = event?.data as JsonObject | undefined + if (event?.type === 'command/run' && data?.commandId === execution.commandId) started = true + if (!started) continue + notifications.push(notification) + if (event?.type !== 'command/done' || data?.commandId !== execution.commandId) continue + expect(execution.result.kind).toBe(data.kind) + expect(execution.result.text).toBe(data.text) + if (execution.result.kind === 'success') expect(execution.result.sourceEventSeq).toBe(data.sourceEventSeq) + return + } + throw new Error('SDK command response arrived without its preceding command lifecycle notifications') +} + // These recorded compositions also pin pre-desktop SDK or headless headers. // New desktop-context cases exercise the shipped SDK defaults without this patch. const legacySdkCompositions = new Set([ @@ -612,6 +660,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ initialWorkspace: WorkspaceSnapshotEntry[] finalWorkspace: WorkspaceSnapshotEntry[] cwd: string + modelRequests: JsonObject[] }> { const workspaceParent = await realpath(tmpdir()) let workspacePrefix = `sdk-snapshot-${scenario.name}-` @@ -662,7 +711,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ignoredRootEntries: RUNTIME_WORKSPACE_ENTRIES, }) const [parentFixture, ...childFixtures] = replayFixtures - const actions = turnActions(primaryFixture) + const actions = sdkActions(primaryFixture) const resumeReplayFile = scenario.name === 'session-resume' && !recording ? join(patchRoot, 'resume-replay.json') : undefined const prepareTurnReplay = async (turn: number): Promise => { @@ -671,7 +720,8 @@ async function runScenario(scenario: CorpusScenario): Promise<{ .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 firstTurn = actions.find(action => action.kind === 'turn') + if (firstTurn !== undefined) await prepareTurnReplay(firstTurn.turn) const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, ...recording ? {} : REPLAY_CREDENTIAL_ENV, @@ -745,6 +795,16 @@ async function runScenario(scenario: CorpusScenario): Promise<{ try { let session = harness.session(sessionId) for (const action of actions) { + if (action.kind === 'command') { + await session.query(action.line) + let execution: CommandExecution | undefined + for await (const message of session.receiveResponse()) { + if ('commandId' in message) execution = message + } + if (execution === undefined) throw new Error('SDK command query returned no command result') + collectCommandNotifications(subscription, sessionId, execution, notifications, observe) + continue + } if (action.content === undefined) { await waitForRootEvent( subscription, @@ -765,12 +825,29 @@ async function runScenario(scenario: CorpusScenario): Promise<{ 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) - observe(notification) - }, - }) + let result: RunResult | undefined + if (scenario.name === 'manual-compact') { + const content = action.content[0] + if (action.content.length !== 1 || content?.type !== 'text' || typeof content.text !== 'string') { + throw new Error('manual compact query fixture requires one text block per prompt') + } + await session.query(content.text) + for await (const message of session.receiveResponse()) { + if ('finalResponse' in message) result = message + else if ('method' in message) { + notifications.push(message) + observe(message) + } + } + if (result === undefined) throw new Error('SDK prompt query returned no activity result') + } else { + result = await session.run(materializeInput(action.content, scenario, cwd, liveSessions), { + onNotification: (notification) => { + notifications.push(notification) + observe(notification) + }, + }) + } results.push(result) if (titleSubscription !== undefined) { const expected = expectedTitles[results.length - 1]?.data as JsonObject | undefined @@ -821,7 +898,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ observe, ) } - for (const type of postTurnEventTypes(primaryFixture)) { + for (const type of postActionEventTypes(primaryFixture)) { await waitForRootEvent(subscription, sessionId, event => event.type === type, observe) } } finally { @@ -837,7 +914,10 @@ async function runScenario(scenario: CorpusScenario): Promise<{ const finalWorkspace = await captureWorkspaceSnapshot(cwd, { ignoredRootEntries: RUNTIME_WORKSPACE_ENTRIES, }) - return { results, notifications, observedMethods, logs, initialWorkspace, finalWorkspace, cwd } + const modelRequests = scenario.name === 'manual-compact' + ? records(await readFile(join(dshHome, 'compact-requests.jsonl'), 'utf8')) + : [] + return { results, notifications, observedMethods, logs, initialWorkspace, finalWorkspace, cwd, modelRequests } } finally { await harness.close() await rm(cwd, { recursive: true, force: true }) @@ -1002,7 +1082,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(writerFiles, 'native writer oracle inventory').toEqual(retained ? files.map((_, index) => writerSnapshotName(index)).sort() : []) } - const { results, notifications, observedMethods, logs, initialWorkspace, finalWorkspace, cwd } = await runScenario(scenario) + const { results, notifications, observedMethods, logs, initialWorkspace, finalWorkspace, cwd, modelRequests } = await runScenario(scenario) const ordered = orderLogs( logs, recording ? logs.length : files.length, @@ -1010,6 +1090,62 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { ) reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate') const actualContext = contextOf(ordered, cwd) + if (scenario.name === 'manual-compact') { + const parent = ordered[0] + if (parent === undefined) throw new Error('manual compact has no persisted Session') + const events = parseSessionLog(parent.content) + const commands = events.filter(event => event.type === 'command/run') + expect(commands).toHaveLength(1) + const command = commands[0] + if (command === undefined) throw new Error('manual compact has no command record') + expect(command.data.name).toBe('compact') + const summaries = events.filter(event => event.type === 'compaction/summary') + expect(summaries).toHaveLength(1) + const summary = summaries[0] + if (summary === undefined) throw new Error('manual compact has no summary') + expect(summary.data).toMatchObject({ sourceCommandId: command.data.commandId, llmStreamCall: true }) + const bracket = events.filter(event => event.type === 'compaction/start' || event.type === 'compaction/end') + expect(bracket.map(event => event.type)).toEqual(['compaction/start', 'compaction/end']) + for (const event of bracket) expect(event.data).toMatchObject({ turn: null, sourceCommandId: command.data.commandId }) + const done = events.find(event => event.type === 'command/done') + expect(done?.data).toMatchObject({ commandId: command.data.commandId, kind: 'success', sourceEventSeq: summary.seq }) + const checkpoint = events.find(event => event.type === 'user/message' + && event.data.source.kind === 'compact-checkpoint') + expect(checkpoint?.surfaceOp).toEqual({ + op: 'replace', startSeq: summary.data.shadowedRange.start, endSeq: summary.data.shadowedRange.end, + }) + expect(events.filter(event => event.type === 'turn/start').map(event => event.data.turn)).toEqual([1, 2]) + expect(events.filter(event => event.type === 'step/start')).toHaveLength(2) + const commandNotifications = notifications.flatMap(notification => { + const event = notification.params.sessionId === parent.header.id ? notificationEvent(notification) : undefined + return event?.type === 'command/run' || event?.type === 'command/done' ? [event.type] : [] + }) + expect(commandNotifications).toEqual(['command/run', 'command/done']) + + expect(modelRequests.map(request => request.purpose)).toEqual([undefined, 'compaction', undefined]) + const [initialRequest, summaryRequest, continuationRequest] = modelRequests + if (initialRequest === undefined || summaryRequest === undefined || continuationRequest === undefined) { + throw new Error('manual compact requires an initial, auxiliary, and continuation request') + } + const initialMessages = initialRequest.messages as JsonObject[] + const summaryMessages = summaryRequest.messages as JsonObject[] + const continuationMessages = continuationRequest.messages as JsonObject[] + expect(summaryRequest.tools).toEqual(initialRequest.tools) + expect(continuationRequest.tools).toEqual(initialRequest.tools) + expect(continuationRequest.provider).toBe(initialRequest.provider) + expect(continuationRequest.model).toBe(initialRequest.model) + expect(continuationMessages[0]).toEqual(initialMessages[0]) + expect(summaryMessages.slice(0, -1)).toEqual(initialMessages) + expect(summaryMessages.at(-1)).toMatchObject({ role: 'user', content: [{ + type: 'text', text: expect.stringContaining('You are now acting as a compaction engine'), + }] }) + const continuationBlocks = continuationMessages.flatMap(message => message.content as JsonObject[]) + expect(continuationBlocks).toEqual(expect.arrayContaining(summary.data.summary)) + const texts = continuationBlocks.flatMap(block => block.type === 'text' ? [block.text] : []) + expect(texts).not.toContain('/compact') + expect(texts).not.toContain(done?.data.text) + expect(texts).toContain('SDK_COMPACT_READY') + } if (scenario.name === 'minimal-image-compaction') { const events = results.flatMap(result => result.events) const failures = events.filter(event => event.type === 'turn/end' && event.data.reason.kind === 'error')