Merge branch 'master' into fix/todo-tool-ui

This commit is contained in:
imccyu
2026-07-28 11:32:07 +08:00
committed by GitHub
37 changed files with 316 additions and 300 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/session-reference/README.md
README.md: 2ca461f88b266b4dec1ffa4132c8cb17455f4b8e
README.zh.md: 9f8fd0bace9b37b2f7885eded7686ecac8c625ba
README.md: 6def2923cf3bc0021b0db578279a1b0571106d41
README.zh.md: 9d7abfa78e6d35b2149c9436d5b397e4a30404d7

View File

@@ -6,7 +6,7 @@ English | [中文](README.zh.md)
## Public API
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched.
- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched.
- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `UserMessageData` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
- `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
@@ -21,7 +21,7 @@ The context source is `{ kind: 'session-reference', version: 1, references }`; e
| Key | Default | Contract |
|---|---:|---|
| `maxReferences` | `3` | Maximum distinct source sessions in one prepared message; must be at most `3`. |
| `candidateLimit` | `50` | Default metadata candidate count returned to a host. |
| `candidateLimit` | `50` | Default candidate count returned to a host. |
| `maxReferenceBytes` | `65536` | Maximum serialized JSON bytes for one reference object. |
Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context.
@@ -44,7 +44,7 @@ The snapshot and request are consecutive append-only target messages and preserv
## Known Limitations and Deferred Work
- **No title or full-text discovery** — candidates filter by session id and cwd only, although selected rows display the latest title. SQLite FTS may replace discovery later without changing URI, snapshot, or persistence contracts.
- **No body discovery** — candidate queries inspect folded titles but do not search message bodies. A non-empty query may inspect every visible persisted session log through the session-query service's bounded, cancellable batch; a dedicated title index may replace that discovery path without changing URI, snapshot, or persistence contracts.
- **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool.
- **Text projection only** — non-text user and assistant blocks are not propagated across sessions.
- **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations.

View File

@@ -6,7 +6,7 @@
## 公开 API
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 idcwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label,并回退到会话 id不搜索标题与消息主体。
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 idcwd 或日志中最新的标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用标题作为 mention label;标题不存在或无法读取时回退到会话 id不搜索消息主体。
- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合 `UserMessageData` 上下文。任何无效引用、读取失败、取消或预算失败都会在宿主调用 `followup()``steer()` 之前被拒绝。
- `encodeSessionReferenceUri()``decodeSessionReferenceUri()` 实现 `dsh-session:<base64url(JSON.stringify(sessionId))>`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)``parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。显式 Markdown mention 会拒绝每个格式错误的 URI只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。
@@ -21,7 +21,7 @@
| Key | 默认值 | 契约 |
|---|---:|---|
| `maxReferences` | `3` | 一条已准备消息中不同源会话的最大数量;必须不大于 `3`。 |
| `candidateLimit` | `50` | 返回给宿主的默认元数据候选数量。 |
| `candidateLimit` | `50` | 返回给宿主的默认候选数量。 |
| `maxReferenceBytes` | `65536` | 一个引用对象的最大序列化 JSON 字节数。 |
保留会对每个源独立应用 `maxReferenceBytes`,保留 compact 检查点与最新消息,再丢弃较旧的非检查点单元,并使用 `dsh-retention` 头部/尾部截断和精确 UTF-8 省略通知。如果某个源的固定序列化字段无法容纳,准备会以 `SESSION_REFERENCE_BUDGET_EXCEEDED` 失败,而不返回部分上下文。
@@ -44,7 +44,7 @@
## 已知限制与暂缓事项
- **没有标题或全文发现**:候选会话只按会话 id 与 cwd 筛选但已选行会显示最新标题。SQLite FTS 未来可以替换发现机制,而不改变 URI、快照或持久化契约。
- **不支持正文发现**:候选查询会检查折叠后的标题,但不搜索消息主体。非空查询可能通过 session-query 服务有界、可取消的批处理检查每个可见的持久化会话日志;专用标题索引未来可以替换这条发现路径,而不改变 URI、快照或持久化契约。
- **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。
- **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。
- **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。

View File

@@ -10,7 +10,7 @@ import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query'
import {
DEFAULT_CANDIDATE_LIMIT,
DEFAULT_MAX_REFERENCE_BYTES,
@@ -102,7 +102,7 @@ export class SessionReferenceService extends Service {
/**
* List reference candidates, ranked by working-directory affinity.
* @param agent - target agent; self is excluded and its cwd drives ranking.
* @param query - optional case-insensitive session-id/cwd substring.
* @param query - optional case-insensitive session-id/cwd/title substring.
* @param limit - optional positive result cap.
* @param signal - optional cancellation boundary for host autocomplete teardown.
* @returns candidates labeled by latest title or, when absent, session id.
@@ -119,27 +119,42 @@ export class SessionReferenceService extends Service {
const needle = query.toLocaleLowerCase()
const targetCwd = agent.session.header.cwd
assertNotCancelled(signal)
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(), signal))
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal))
.filter(record => record.header.id !== agent.id)
.filter((record) => {
if (needle === '') return true
return record.header.id.toLocaleLowerCase().includes(needle)
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
})
.map((record, index) => ({ record, index }))
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|| a.index - b.index)
.slice(0, limit)
const titles = await settleWithCancellation(
Promise.all(records.map(({ record }) => this.ctx.sessionQuery.readTitle(record.header.id))),
const inspected = needle === ''
? records
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|| a.index - b.index)
.slice(0, limit)
: records
const observations = await settleWithCancellation(
this.ctx.sessionQuery.readTitleSnapshots(inspected.map(({ record }) => record.header.id), signal),
signal,
)
return records.map(({ record }, index) => ({
sessionId: record.header.id,
label: titles[index]?.title ?? record.header.id,
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
createdAt: record.header.createdAt,
}))
return inspected.map(({ record, index }, observationIndex) => {
const observation = observations[observationIndex] as SessionTitleObservationResult
return {
record,
index,
label: observation.status === 'fulfilled'
? observation.value.title?.title ?? record.header.id
: record.header.id,
}
}).filter(({ record, label }) => {
if (needle === '') return true
return record.header.id.toLocaleLowerCase().includes(needle)
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
|| label.toLocaleLowerCase().includes(needle)
}).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|| a.index - b.index)
.slice(0, limit)
.map(({ record, label }) => ({
sessionId: record.header.id,
label,
...record.header.cwd === undefined ? {} : { cwd: record.header.cwd },
createdAt: record.header.createdAt,
}))
}
/**

View File

@@ -188,7 +188,7 @@ describe('session reference URI and inline mentions', () => {
})
describe('session reference discovery and preparation', () => {
it('ranks metadata candidates by cwd without depending on full-text search', async () => {
it('matches candidate metadata and titles before ranking by cwd', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } })
ctx.sessions.create(SessionId('other'), { meta: { cwd: '/else', createdAt: 40 } })
@@ -210,6 +210,9 @@ describe('session reference discovery and preparation', () => {
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 },
])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 },
])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), '', 0))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
@@ -229,6 +232,40 @@ describe('session reference discovery and preparation', () => {
listSessions.mockRestore()
})
it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
readTitles.mockResolvedValueOnce([{
sessionId: source.id,
status: 'rejected',
reason: new Error('broken title log'),
}])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
{ sessionId: source.id, label: source.id, createdAt: source.header.createdAt },
])
let releaseTitles: (() => void) | undefined
let titleSignal: AbortSignal | undefined
readTitles.mockImplementationOnce(async (_ids, signal) => {
titleSignal = signal
await new Promise<void>((resolve) => { releaseTitles = resolve })
return []
})
const controller = new AbortController()
const pending = ctx.sessionReferences.listCandidates(fakeAgent(target), 'source', undefined, controller.signal)
await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') })
expect(titleSignal).toBe(controller.signal)
const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
controller.abort('autocomplete superseded')
await cancelledTitles
releaseTitles?.()
await Promise.resolve()
readTitles.mockRestore()
})
it('projects only the current user/assistant surface and records snapshot metadata', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/target' } })

View File

@@ -602,7 +602,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'async listCandidates( agent: Agent, query = \'\', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
jsDoc: '/**\n * List reference candidates, ranked by working-directory affinity.\n * @param agent - target agent; self is excluded and its cwd drives ranking.\n * @param query - optional case-insensitive session-id/cwd/title substring.\n * @param limit - optional positive result cap.\n * @param signal - optional cancellation boundary for host autocomplete teardown.\n * @returns candidates labeled by latest title or, when absent, session id.\n */',
},
{
signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise<PreparedReferencedMessage>',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sdk/sdk-client/README.md
README.md: e2aaf08212307bfac0c73b5e838679a7a750a92a
README.zh.md: cbefae59d95cc0cb9d89145ad3f2ee3248822714
README.md: 33a933e10abfa865cf9ce34b87c377d07081cc68
README.zh.md: 9f4453a00efef2685acec0194f83fcec2edf1409

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
@@ -20,11 +20,11 @@ const result = await harness.run('say hi')
console.log(result.status, result.finalResponse)
```
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), plus every `session.event` envelope and raw notification observed for that session tree, in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
## HarnessClient
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `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. Error surfaces are typed: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `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. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API`HarnessClient` 是低层协议客户端。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness其组成由自己的 `cordis.yml` 决定。
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层回合 API`HarnessClient` 是低层协议客户端。package根枚举消费方接口两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所生成的运行时进程是一个完整 harness其组成由自己的 `cordis.yml` 决定。
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费者——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
@@ -20,11 +20,11 @@ const result = await harness.run('say hi')
console.log(result.status, result.finalResponse)
```
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult``status`(按部署映射的 `ok`/`error`)、结构化 `reason``TurnEndReason`)、`finalResponse`(最后一条助手消息文本),以及该会话树内按线序观察到的全部 `session.event` 封套与原始通知。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被收割。`start()` 记忆化 `initialize` 握手(工作区 cwd——在跨越线之前解析为绝对路径——加 provider/model 路由);握手失败会收割运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个 prompt 回合,在配对的 `session.finished` 到达时尘埃落定,返回 `TurnResult``status`(按部署映射的 `ok`/`error`)、结构化 `reason``TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按线序排列。模型层失败是 `status: 'error'` 的结果,绝不是拒绝;拒绝意味着传输丢失、超时或协议违例。
## HarnessClient
回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型:`JsonRpcResponseError`(线上错误响应,保留 code/data`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
回合 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。错误表面有类型且由本包导出`JsonRpcResponseError`(线上错误响应,保留 code/data`RequestTimeoutError`(配置的时限已到;线上没有取消方法,请求在服务端继续运行直到 close`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000`disposeGraceMs` 默认 3000直到进程真正退出。该阶梯为本客户端私有它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该接缝记载的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。

View File

@@ -15,7 +15,6 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [

View File

@@ -71,11 +71,28 @@ interface SubscriptionState {
failure: Error | undefined
}
/**
* One client-side notification stream. Delivery order matches the wire;
* {@link close} detaches it from the client, after which {@link next} rejects.
*/
export class NotificationSubscription implements AsyncIterable<HarnessNotification> {
/** One client-side notification stream returned by {@link HarnessClient.subscribe}. */
export interface NotificationSubscription extends AsyncIterable<HarnessNotification> {
/**
* Await the next matching notification.
* @returns the notification; after the runtime died, drains what was
* already delivered and then rejects; after {@link close}, rejects
* immediately (the queue is dropped).
*/
next(): Promise<HarnessNotification>
/**
* Drain one already-delivered notification without waiting.
* @returns the next queued notification, or `undefined` when none is queued.
*/
tryNext(): HarnessNotification | undefined
/** Detach from the client; queued items drop and pending waiters reject. */
close(): void
}
/** Internal producer side of a public notification subscription. */
class NotificationSubscriptionImpl implements NotificationSubscription {
constructor(
private readonly state: SubscriptionState,
private readonly unsubscribe: () => void,
@@ -168,7 +185,7 @@ export class HarnessClient {
private child: ChildProcess | undefined
private transport: JsonRpcLineTransport | undefined
private readonly stderrTail: string[] = []
private readonly subscriptions = new Map<string, NotificationSubscription>()
private readonly subscriptions = new Map<string, NotificationSubscriptionImpl>()
private readonly sessionParents = new Map<string, string>()
private subscriptionSerial = 0
private exitCode: number | null | undefined
@@ -324,7 +341,7 @@ export class HarnessClient {
subscribe(filter?: NotificationFilter): NotificationSubscription {
const id = String(this.subscriptionSerial++)
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) })
const subscription = new NotificationSubscriptionImpl(state, () => { this.subscriptions.delete(id) })
if (this.closeTask !== undefined || this.exitCode !== undefined || this.spawnError !== undefined) {
subscription.fail(this.closedError('DeepSeek Harness runtime closed'))
return subscription

View File

@@ -9,6 +9,21 @@
* @module @deepseek-ai/dsh-sdk-client
*/
export * from './api.ts'
export * from './client.ts'
export type * from './types.ts'
export { DeepSeekHarness, HarnessSession } from './api.ts'
export type { RunOptions } from './api.ts'
export {
HarnessClient,
RequestTimeoutError,
SdkProtocolError,
TransportClosedError,
} from './client.ts'
export type { NotificationSubscription } from './client.ts'
export { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol'
export type {
ContentBlock,
DeepSeekHarnessOptions,
HarnessClientOptions,
HarnessNotification,
NotificationFilter,
TurnResult,
} from './types.ts'

View File

@@ -67,9 +67,9 @@ export interface TurnResult {
reason: TurnEndReason | undefined
/** Concatenated text of the session's last assistant message (empty when none). */
finalResponse: string
/** Every `session.event` payload for this session tree, in wire order. */
/** Every `session.event` payload for the root session, in wire order. */
events: SessionEvent[]
/** Every notification observed during the turn, in wire order. */
/** Every notification for the root session and discovered descendants, in wire order. */
notifications: HarnessNotification[]
}

View File

@@ -12,15 +12,14 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import {
DeepSeekHarness,
finalResponse,
HarnessClient,
normalizeInput,
JsonRpcResponseError,
RequestTimeoutError,
SdkProtocolError,
TransportClosedError,
type HarnessNotification,
} from '../src/index.ts'
import { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol'
import { finalResponse, normalizeInput } from '../src/api.ts'
const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url))
@@ -69,7 +68,7 @@ describe('DeepSeekHarness', () => {
await harness.close()
})
it('streams notifications to the observer and scopes them to the session tree', async () => {
it('keeps events root-scoped while streaming notifications for the session tree', async () => {
const harness = harnessWith({ FAKE_SUBAGENT: '1' })
const seen: HarnessNotification[] = []
const result = await harness.run('delegate', {
@@ -83,7 +82,8 @@ describe('DeepSeekHarness', () => {
expect(seen.map(n => n.method)).toContain('subagent.finished')
const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
expect(childEvents.length).toBeGreaterThan(0)
// Child events do not count as the parent's own turn events.
// TurnResult.events is the root session's typed stream; descendants retain
// their session ids in the raw notification stream above.
expect(result.events.every(event => event.type !== 'assistant/message'
|| (event.data as { content: { type: string; text?: string }[] }).content[0]?.text !== 'child says hi')).toBe(true)
await harness.close()

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 61ffc0e17700d79da14001b389c7c6dcb50ee28d
README.zh.md: 9de816dc588354d04194d5eb99f444409456046e
# pnpm run verify-translation-pairing --write packages/sdk/sdk-protocol/README.md
README.md: 79e6bc36a656ce0d68c8e01ab2f75e26b4ac8ca5
README.zh.md: 8322da0f2bf7251f2b958c6a15f1738b9d8c41a4

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The server side is the [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) plugin; clients are [`dsh-sdk-client`](../sdk-client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration.
The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The package root enumerates the protocol consumer interface; source modules are not exported as deep imports. The server side is the [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) plugin; clients are [`dsh-sdk-client`](../sdk-client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration.
## Transport

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON-RPC 2.0 传输类,加上线两端共同使用的具名请求、结果与通知类型。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)TypeScript与 [Python SDK](../../../python/README.md)(后者镜像这些形状但不导入它们)。纯库——无插件、无 Config、无注册。
DeepSeek Harness SDK 运行时的共享线协议:一个按换行分帧的 JSON-RPC 2.0 传输类,加上线两端共同使用的具名请求、结果与通知类型。package根枚举协议消费方接口源模块不以深层导入形式导出。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)TypeScript与 [Python SDK](../../../python/README.md)(后者镜像这些形状但不导入它们)。纯库——无插件、无 Config、无注册。
## 传输

View File

@@ -15,7 +15,6 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [

View File

@@ -8,5 +8,18 @@
* @module @deepseek-ai/dsh-sdk-protocol
*/
export * from './transport.ts'
export * from './types.ts'
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
export type { JsonRpcTransportPeer } from './transport.ts'
export type {
HarnessSdkNotificationMap,
HarnessSdkRequestMap,
InitializeParams,
InitializeResult,
SdkRunStatus,
SessionEventNotification,
SessionFinishedNotification,
SessionPromptParams,
SessionPromptResult,
SubagentFinishedNotification,
SubagentStartedNotification,
} from './types.ts'

View File

@@ -0,0 +1,31 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=14 viewportRow=8 bufferRow=8
viewport
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " main-session"
style 1-12 dim
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
style 0-17 fg=bright-blue bold
style 18-31 fg=bright-black
style 34-50 fg=bright-black
style 53-57 fg=bright-black
style 60-69 fg=bright-black
8| " dsh > @design "
style 1-3 fg=bright-blue bold
style 5-6 fg=bright-black
style 14-14 inverse
9| " → Session · Searchable design re opaque-source-id · /workspace/project · 1970-01-01T0 "
style 7-38 fg=bright-blue
10-35| <blank>

View File

@@ -8,6 +8,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type JsonValue, type Session } from '@deepseek-ai/dsh-session'
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -21,6 +22,7 @@ import {
type TuiHarnessOptions,
} from './harness.ts'
import { HeadlessTerminal, type TerminalSnapshotOptions } from './headless-terminal.ts'
import { TestSessionQueryService } from './session-query.ts'
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
@@ -35,6 +37,7 @@ const CHECKPOINTS = [
'retry-exhausted',
'banner-gradient',
'file-autocomplete',
'session-title-autocomplete',
'code-mode-pending',
'dynamic-workflow-pending',
'cordis-tools-pending',
@@ -399,6 +402,30 @@ describe('TUI terminal-state snapshots', () => {
}
})
it('pins session autocomplete discovered through a log-backed title', async () => {
const harness = await setupSnapshot({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(SessionId('opaque-source-id'), {
meta: { cwd: '/workspace/project', createdAt: 1 },
})
source.append('session/title', {
title: 'Searchable design review',
messageSeqs: [],
source: { kind: 'fallback' },
})
},
})
harness.terminal.send('@design')
await vi.waitFor(async () => {
expect(await harness.terminal.snapshot()).toContain('Session · Searchable design re')
})
await checkpoint('session-title-autocomplete', harness.terminal)
await disposeSnapshot(harness)
})
it('pins Code Mode run_code with its production presenter', async () => {
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
const call = {

View File

@@ -2271,21 +2271,50 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
let sourceId = SessionId('uninitialized')
const sourceId = SessionId('source-session')
const sourceHeader: SessionHeader = {
version: 0,
id: sourceId,
cwd: '/workspace',
createdAt: 1,
}
const noCwdHeader: SessionHeader = {
version: 0,
id: SessionId('no-cwd'),
createdAt: 2,
}
const sourceEvents: SessionEvent[] = [
{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
surfaceOp: 'append',
},
{
type: 'session/title',
seq: 1,
time: 2,
data: {
title: 'Source chat',
messageSeqs: [0],
source: { kind: 'fallback' },
},
},
]
const result = await setup({
sessionPersistence: {
list: async () => [noCwdHeader, sourceHeader],
load: async (id) => {
if (id === sourceId) return { meta: sourceHeader, events: sourceEvents }
if (id === noCwdHeader.id) return { meta: noCwdHeader, events: [] }
throw new Error(`unexpected persisted session ${id}`)
},
},
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
sourceId = source.id
appendUser(source, 'source background')
source.append('session/title', {
title: 'Source chat',
messageSeqs: [0],
source: { kind: 'fallback' },
})
ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } })
},
})
@@ -2294,7 +2323,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('(no cwd)')
result.terminal.send('\x03')
result.terminal.send('@source-session')
result.terminal.send('@chat')
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') })
expect(result.terminal.output).toContain('source-session')
result.terminal.send('\t')