Merge branch 'master' into fix/todo-tool-ui
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 公开 API
|
||||
|
||||
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id 或 cwd 进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用最新的日志支持标题作为 mention label,并回退到会话 id;不搜索标题与消息主体。
|
||||
- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或日志中最新的标题进行不区分大小写的筛选,再按同 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、恢复、订阅或源会话变更。
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 托管传输例外。幂等,已关闭的客户端拒绝复用。
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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、无注册。
|
||||
|
||||
## 传输
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user