Merge remote-tracking branch 'origin/master' into worktree/sidebar-subagent-activity

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-08 16:37:29 +08:00
361 changed files with 3950 additions and 3337 deletions

View File

@@ -41,6 +41,6 @@
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"cordis": "^4.0.0-rc.7",
"node-addon-landlock-run": "0.0.0-test.0"
"@deepseek-ai/node-addon-landlock-run": "workspace:*"
}
}

View File

@@ -5,7 +5,7 @@ import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { launcherPath } from 'node-addon-landlock-run'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
@@ -13,14 +13,14 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
/**
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
* rung forced off, so the npm-distributed `landlock-run` confines) underneath the
* rung forced off, so the workspace `landlock-run` launcher confines) underneath the
* REAL `SandboxBashExecutor`, driven through the executor's public run/start
* paths. Verifies the WORLD (files exist or don't) plus the stamped result
* facts; the backend-only confinement proofs live with
* `@deepseek-ai/dsh-sandbox-local`.
*
* Self-skips when the running kernel does not enforce Landlock; the
* launcher binary itself arrives with `pnpm install` (`node-addon-landlock-run`).
* Self-skips when the running kernel does not enforce Landlock. CI builds the launcher from
* `native/landlock-run` before running this file.
*/
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })

View File

@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../native/landlock-run/packages/entry"
},
{
"path": "../../util/brand"
},

View File

@@ -218,6 +218,10 @@
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
- id: skill-badge
name: '@deepseek-ai/dsh-skill-badge'
disabled: true
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'

View File

@@ -70,6 +70,7 @@
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-badge": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",

View File

@@ -0,0 +1 @@
[]

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/bundle/headless/README.md
README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca
README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0
README.md: 661b377817482d22f58f22b573075722646729a2
README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row.
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`因此序列化、zod、SSEServer-Sent Events帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0否则 1。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`因此序列化、zod、SSEServer-Sent Events帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0否则 1。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错
## 模型体验

View File

@@ -6,8 +6,7 @@
* (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
* chain — serialization, zod, SSE framing — really runs), prints the final
* assistant text at agent quiescence, and exits (completed → 0, else 1). The
* task text arrives as launcher-patched config
* (`dsh --profile headless "task"`).
* task text arrives as launcher-patched config (`dsh run "task"`).
* @module @deepseek-ai/dsh-headless
*/
@@ -86,26 +85,31 @@ async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
* `agent/status` subscription; the stream itself carries no status frame.
* @param frames - the mux stream opened before the prompt.
* @param sessionId - the headless session.
* @param idle - resolves when the agent reaches quiescence.
* @param idle - resolves to the final session-event sequence when the agent reaches quiescence.
* @param io - process-facing effects for stream diagnostics.
* @returns the aggregated outcome.
*/
async function consumeUntilIdle(
frames: AsyncIterable<RpcRequest<MuxFrame>>,
sessionId: SessionId,
idle: Promise<void>,
idle: Promise<number>,
io: HeadlessIo,
): Promise<TurnOutcome> {
let started = false
let text = ''
let reason: string = 'error'
void (async () => {
let observedSeq = -1
let resolveProgress: (() => void) | undefined
const streamDone = (async () => {
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') return
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
observedSeq = event.seq
resolveProgress?.()
resolveProgress = undefined
if (event.type === 'turn/start') {
started = true
continue
@@ -121,7 +125,12 @@ async function consumeUntilIdle(
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
})()
await idle
const streamEnded = streamDone.then(() => 'ended' as const)
const idleSeq = await idle
while (observedSeq < idleSeq) {
const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } })
if (await Promise.race([progress, streamEnded]) === 'ended') break
}
return { text, reason }
}
@@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void {
// port of this runner must replace it with a wire-visible idle signal.
const abort = new AbortController()
const frames = api.events.mux({}, abort.signal)
const idle = new Promise<void>((resolve) => {
const idle = new Promise<number>((resolve) => {
ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === created.sessionId && status === 'idle') resolve()
if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
})
})
const done = consumeUntilIdle(frames, created.sessionId, idle, io)

View File

@@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent {
interface RpcShapedRequest { rpcId: string }
interface ScriptedApiOptions {
promptFails?: boolean
framesAfterPrompt?: boolean
onPrompt?: () => void
}
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown {
function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown {
let releaseFrames = (): void => {}
const framesReady = options.framesAfterPrompt === true
? new Promise<void>((resolve) => { releaseFrames = resolve })
: Promise.resolve()
const prepared = events.map((event) => {
if (event.type === 'stream/error') return { streamError: true } as const
const { sessionId = 'S1', ...rest } = event
return { streamError: false, sessionId, event: stamped(rest) } as const
})
return {
sessions: {
create: (request: RpcShapedRequest) =>
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true
// A code from the closed wire union: the carrier schema rejects invented codes.
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }),
prompt: (request: RpcShapedRequest) => {
releaseFrames()
options.onPrompt?.()
return Promise.resolve(options.promptFails === true
// A code from the closed wire union: the carrier schema rejects invented codes.
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } })
},
},
events: {
mux: async function* () {
for (const event of events) {
if (event.type === 'stream/error') {
await framesReady
for (const item of prepared) {
if (item.streamError) {
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
continue
}
const { sessionId = 'S1', ...rest } = event
yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } }
yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } }
}
},
},
@@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean }
* Mount the runner against a scripted API, emit the idle transition after the
* scripted frames drain, and wait for its exit request.
*/
async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> {
async function run(
events: ScriptedEvent[],
options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {},
): Promise<{ code: number; out: string; err: string }> {
const ctx = new Context()
let out = ''
let err = ''
@@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } =
}
ctx.provide('headlessIo', io)
})
ctx.provide('apiProxy', scriptedApi(events, options) as never)
const emitIdle = (): void => {
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
}
ctx.provide('apiProxy', scriptedApi(events, {
...options.promptFails === undefined ? {} : { promptFails: options.promptFails },
...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt },
...options.idleInPrompt === true ? { onPrompt: emitIdle } : {},
}) as never)
ctx.provide('httpServer', { port: 12345 } as never)
apply(ctx, { task: 'do the thing' })
// Quiescence is out of band: give the scripted stream a beat to drain, then
// flip the agent idle exactly as the loop would. Foreign agents and
// non-idle transitions must not settle the run.
await new Promise(resolve => setTimeout(resolve, 10))
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
if (options.idleInPrompt !== true) {
await new Promise(resolve => setTimeout(resolve, 10))
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
emitIdle()
}
const code = await exited
await ctx.fiber.dispose()
return { code, out, err }
@@ -106,6 +137,15 @@ describe('headless runner', () => {
expect(err).toContain('observing at http://127.0.0.1:12345')
})
it('consumes through the idle sequence when queued frames arrive after the status transition', async () => {
const { code, out } = await run(
[messageTurn, text(1, 'race-free answer'), end(1, 'completed')],
{ framesAfterPrompt: true, idleInPrompt: true },
)
expect(code).toBe(0)
expect(out).toBe('race-free answer\n')
})
it('exits 1 when the final turn ends for any other reason', async () => {
const { code } = await run([messageTurn, end(1, 'aborted')])
expect(code).toBe(1)
@@ -168,7 +208,7 @@ describe('headless runner', () => {
ctx.provide('httpServer', { port: 1 } as never)
apply(ctx, { task: 't' })
await new Promise(resolve => setTimeout(resolve, 10))
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
expect(await exited).toBe(1)
expect(err).toContain('event stream failed')
await ctx.fiber.dispose()

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/client/runtime/README.md
README.md: 00427f33b1dfc23b157c8fe4cfefb42cf313ee66
README.zh.md: 0a27602a4408792e7f02ecd3995aeb5946e81aab
README.md: bb85d3c7b45eb0d82d789a9133bba787f1d6c5e9
README.zh.md: 18aedbb487a490c85ce68ccc460fd82218157843

View File

@@ -38,7 +38,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
## Request inspection

View File

@@ -38,7 +38,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
## 请求检查

View File

@@ -192,6 +192,12 @@ export interface CompactionSummaryNode {
/** Summary text from the checkpoint's `compact/summary` provenance; null when
* the window cut left that provenance outside (the marker is then not expandable). */
summary: string | null
/** Seq of the loaded `compact/summary` event, or null when that provenance is outside the window. */
summaryEventSeq: number | null
/** Number of surface items replaced, or null when summary provenance is unavailable or malformed. */
shadowedItemCount: number | null
/** Estimated token price of the replaced items, or null when summary provenance is unavailable or malformed. */
shadowedTokenCount: number | null
}
/**
@@ -236,7 +242,12 @@ export interface CommandNode {
*/
args: string | null
/** Settlement outcome (done payload); null while the command is still executing. */
outcome: { kind: 'success' | 'error'; text?: string } | null
outcome: {
kind: 'success' | 'error'
text?: string
/** Earlier authoritative domain event for a richer client-computed presentation. */
sourceEventSeq?: number
} | null
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */

View File

@@ -158,6 +158,29 @@ function compactSummaryText(event: SessionEvent): string | null {
return text.trim() === '' ? null : text
}
interface CompactSummaryDetails {
readonly summary: string | null
readonly shadowedItemCount: number | null
readonly shadowedTokenCount: number | null
}
/** Recover human-facing summary material from one structurally narrowed wire event. */
function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails {
const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown }
const shadowedSeqs = data.shadowedSeqs
const tokenCount = data.shadowedTokenCount
return {
summary: compactSummaryText(event),
shadowedItemCount: Array.isArray(shadowedSeqs)
&& shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0)
? shadowedSeqs.length
: null,
shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0
? tokenCount as number
: null,
}
}
/**
* One landed checkpoint -> the human-facing compaction marker. The summary text
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
@@ -172,13 +195,28 @@ function materializeCompaction(
): CompactionSummaryNode {
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
let summary: string | null = null
let summaryEventSeq: number | null = null
let shadowedItemCount: number | null = null
let shadowedTokenCount: number | null = null
for (const seq of sources ?? []) {
const candidate = eventIndex.get(seq)
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
summary = compactSummaryText(candidate)
const details = compactSummaryDetails(candidate)
summary = details.summary
summaryEventSeq = candidate.seq
shadowedItemCount = details.shadowedItemCount
shadowedTokenCount = details.shadowedTokenCount
break
}
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
return {
kind: 'compaction',
seq: checkpoint.seq,
time: checkpoint.time,
summary,
summaryEventSeq,
shadowedItemCount,
shadowedTokenCount,
}
}
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
@@ -323,9 +361,22 @@ export class TranscriptAdapter {
return true
}
if ((event.type as string) !== 'command/done') return false
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const data = event.data as unknown as {
commandId: CommandId
kind: 'success' | 'error'
text?: string
sourceEventSeq?: number
}
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
const sourceEventSeq = data.kind === 'success'
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
? data.sourceEventSeq as number
: undefined
const outcome = {
kind: data.kind,
...data.text === undefined ? {} : { text: data.text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
}
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).

View File

@@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => {
it('recognizes a checkpoint carrying the seam-canonical source', () => {
const adapter = new TranscriptAdapter()
adapter.reset([canonicalCheckpoint(1)])
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it("agrees with the seam's own predicate on the source it recognizes", () => {

View File

@@ -92,8 +92,19 @@ export const ev = {
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
commandDone: (
seq: number,
commandId: string,
kind: 'success' | 'error' = 'success',
text?: string,
sourceEventSeq?: number,
): SessionEvent =>
at(seq, { type: 'command/done', data: {
commandId,
kind,
...text === undefined ? {} : { text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
} }),
/** A compaction's log-only `compact/summary` provenance record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compact/summary', data: {

View File

@@ -245,8 +245,14 @@ describe('TranscriptAdapter', () => {
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
])
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' },
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
{
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
@@ -318,7 +324,7 @@ describe('TranscriptAdapter', () => {
...(summary === undefined ? [] : [summary]),
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
expect(adapter.nodes()).toMatchObject([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
])
})
@@ -332,7 +338,10 @@ describe('TranscriptAdapter', () => {
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
])
expect(adapter.nodes()).toEqual([
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' },
{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
},
])
})
@@ -346,7 +355,10 @@ describe('TranscriptAdapter', () => {
source: { kind: 'plugin', plugin: 'compact' },
}),
})])
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }])
expect(adapter.nodes()).toEqual([{
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}])
})
it('skips a non-summary provenance seq before reaching the real one', () => {
@@ -490,20 +502,22 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
})
it('renders the /compact row alongside the marker its own command produced', () => {
// The row that reports the compaction is a command node; dropping command
// folding would delete it together with every other slash-command row.
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
const adapter = new TranscriptAdapter()
adapter.reset([
ev.user(0, '压缩前的问题'),
ev.commandRun(1, 'cmd-compact', 'compact'),
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
ev.commandDone(4, 'cmd-compact', 'success', '已压缩'),
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
])
const nodes = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
expect(nodes[1]).toMatchObject({
name: 'compact',
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
})
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
})
})

View File

@@ -230,7 +230,7 @@ function clientConfig(id: string, entry: string): UserConfig {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
cssModules: { pattern: '[hash]_[local]' },
minify: true,
})
const classMap: Record<string, string> = {}
@@ -239,13 +239,13 @@ function clientConfig(id: string, entry: string): UserConfig {
return [
`const css = ${JSON.stringify(code.toString())};`,
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
` const tag = document.createElement('style');`,
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
' const tag = document.createElement(\'style\');',
` tag.dataset.plugin = ${JSON.stringify(id)};`,
` tag.dataset.pluginCss = tagId;`,
` tag.textContent = css;`,
` document.head.appendChild(tag);`,
`}`,
' tag.dataset.pluginCss = tagId;',
' tag.textContent = css;',
' document.head.appendChild(tag);',
'}',
`export default ${JSON.stringify(classMap)};`,
].join('\n')
},
@@ -258,7 +258,7 @@ function clientConfig(id: string, entry: string): UserConfig {
// without exposing that tree as an HTTP route.
sourcemapPathTransform: browserSourcePath,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: `return module.exports; } });`,
footer: 'return module.exports; } });',
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
}

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/client/ui-conversation/README.md
README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013
README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17
README.md: 6b541b840ed67ee6fd735a0643dde8c60f1ec22d
README.zh.md: 01692c395cdb0f50e0fd41ab92f51d9e3ceecb4f

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with structured summary provenance shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
@@ -64,7 +64,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).

View File

@@ -4,7 +4,7 @@
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops share
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录自动压缩使用「上下文已压缩」标题。每个具备结构化摘要溯源的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero编辑器子树首个会话到达时彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
@@ -64,7 +64,6 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
## 已知限制与暂缓事项
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
- **统计行的耗时与速率只覆盖窗口内消息流**LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板没有入口**`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。

View File

@@ -32,6 +32,7 @@ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
@@ -267,17 +268,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
renderSlot: RenderToolRow
node: CommandNode
compaction?: Extract<ConversationNode, { kind: 'compaction' }>
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
const fallback = node.name === 'compact'
? <CompactionCommandCard {...owner} t={t} />
: <GenericCommandCard {...owner} t={t} />
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} t={t} />,
fallback,
})}
</div>
)
@@ -580,6 +585,16 @@ export function ChatView({
/>
)
}
if (item.kind === 'command-compaction') {
return (
<CommandRow
renderSlot={renderSlot}
node={item.command}
compaction={item.compaction}
t={t}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
@@ -642,9 +657,17 @@ export function ChatView({
<div
key={item.key}
className={css.flowItem}
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
data-chat-anchor-key={item.kind === 'node'
? `node:${String(item.node.seq)}`
: item.kind === 'command-compaction'
? `node:${String(item.compaction.seq)}`
: undefined}
data-chat-flow-key={item.key}
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
data-chat-flow-kind={item.kind === 'node'
? item.node.kind
: item.kind === 'command-compaction'
? item.kind
: 'tool-group'}
>
{renderItem(item)}
</div>

View File

@@ -0,0 +1,40 @@
// CompactionCommandCard: the `/compact` command's running row and its
// successful checkpoint disclosure. Outcomes without a checkpoint keep the
// generic command card so no-history, cancellation, and failures retain their
// complete handler-authored text.
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { ToolRow } from './ToolRow.tsx'
interface CompactionCommandCardProps extends CommandRowOwnerProps {
t: ChatViewSlotProps['t']
}
/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */
export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) {
if (compaction !== undefined) {
return (
<CompactionItem
node={compaction}
title="compact"
fallbackSummary={node.outcome?.text ?? null}
t={t}
/>
)
}
if (node.outcome !== null) return <GenericCommandCard node={node} t={t} />
return (
<ToolRow
t={t}
variant="others"
icon={<IconApiOutline14 size={14} />}
title="compact"
summary={t('message.compaction.running')}
body={null}
state="running"
/>
)
}

View File

@@ -9,6 +9,7 @@
import { memo, useState } from 'react'
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconApiOutline14,
IconChevronDownOutline14,
IconChevronRightOutline14,
MarkdownText,
@@ -18,6 +19,10 @@ import css from './MessageItem.module.css'
interface CompactionItemProps {
node: CompactionSummaryNode
/** Optional command title for a manual compaction folded into this marker. */
title?: string
/** Command settlement text used when structured compaction counts are unavailable. */
fallbackSummary?: string | null
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}
@@ -27,10 +32,22 @@ interface CompactionItemProps {
* @param props - the marker node off the snapshot cache.
* @returns the marker row, with the summary disclosure when one is available.
*/
export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) {
export const CompactionItem = memo(function CompactionItem({
node,
title,
fallbackSummary,
t,
}: CompactionItemProps) {
const [expanded, setExpanded] = useState(false)
const expandable = node.summary !== null
const open = expandable && expanded
const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null
? t('message.compaction.completed', {
items: node.shadowedItemCount,
tokens: node.shadowedTokenCount,
})
: fallbackSummary
?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable'))
return (
<div className={css.compactionRow}>
<button
@@ -40,14 +57,20 @@ export const CompactionItem = memo(function CompactionItem({ node, t }: Compacti
aria-expanded={expandable ? open : undefined}
onClick={() => { setExpanded(value => !value) }}
>
<span className={css.compactionLeading}>
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
<span className={css.compactionLeading} aria-hidden>
<span className={css.compactionContextIcon} data-compaction-icon="context">
<IconApiOutline14 />
</span>
<span
className={css.compactionDisclosureIcon}
data-compaction-disclosure={open ? 'expanded' : 'collapsed'}
>
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</span>
</span>
<span className={css.compactionTitle}>{t('message.compaction')}</span>
<span className={css.compactionTitle}>{title ?? t('message.compaction')}</span>
<span className={css.compactionSep} aria-hidden />
<span className={css.compactionSummary}>
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
</span>
<span className={css.compactionSummary}>{summary}</span>
</button>
{open && node.summary !== null
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}

View File

@@ -33,9 +33,9 @@
padding: 2px 0;
}
/* Compaction marker: one dim 24px row with a chevron disclosure for the
summary body. Dimmed title (not label-primary) — the row is a boundary
notice, not conversation content. */
/* Compaction marker: one dim 24px row with a context icon at rest and a
hover/focus disclosure for the summary body. Dimmed title (not
label-primary) — the row is a boundary notice, not conversation content. */
.compactionRow {
padding: 2px 0;
}
@@ -65,15 +65,36 @@
.compactionLeading {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
margin-right: 6px;
color: var(--dsw-alias-label-secondary);
}
.compactionContextIcon,
.compactionDisclosureIcon {
display: inline-flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
}
.compactionDisclosureIcon {
opacity: 0;
}
.compactionButton:not(:disabled):hover .compactionContextIcon,
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
opacity: 0;
}
.compactionButton:not(:disabled):hover .compactionDisclosureIcon,
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
opacity: 1;
}
.compactionTitle {
flex: none;
font-size: 14px;

View File

@@ -9,13 +9,48 @@
* flow share their gates.
*/
import type {
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
| {
kind: 'command-compaction'
key: string
command: CommandNode
compaction: CompactionSummaryNode
}
/** Match explicit command outcome references to exactly one compaction checkpoint. */
function commandCompactionPairs(nodes: readonly ConversationNode[]): {
readonly byCommandId: ReadonlyMap<string, CompactionSummaryNode>
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
} {
const commandsBySource = new Map<number, CommandNode | null>()
for (const node of nodes) {
if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue
const source = node.outcome.sourceEventSeq
if (source === undefined) continue
commandsBySource.set(source, commandsBySource.has(source) ? null : node)
}
const compactionsBySummary = new Map<number, CompactionSummaryNode | null>()
for (const node of nodes) {
if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue
const summary = node.summaryEventSeq
compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node)
}
const byCommandId = new Map<string, CompactionSummaryNode>()
const byCompactionSeq = new Map<number, CommandNode>()
for (const [source, command] of commandsBySource) {
const compaction = compactionsBySummary.get(source)
if (command === null || compaction === undefined || compaction === null) continue
byCommandId.set(command.commandId, compaction)
byCompactionSeq.set(compaction.seq, command)
}
return { byCommandId, byCompactionSeq }
}
/**
* True when the node has model-visible text content worth IconActions chrome.
@@ -115,9 +150,28 @@ export function assistantBranchSeqs(
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
const pairs = commandCompactionPairs(nodes)
let group: ToolResultNode[] | null = null
for (const node of nodes) {
if (rendersNothing(node)) continue
if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) {
continue
}
if (node.kind === 'compaction') {
group = null
const command = pairs.byCompactionSeq.get(node.seq)
if (command !== undefined) {
items.push({
kind: 'command-compaction',
key: `c${command.commandId}`,
command,
compaction: node,
})
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
continue
}
if (node.kind === 'tool-result') {
if (group === null) {
group = [node]
@@ -138,7 +192,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
}
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })
items.push({
kind: 'node',
key: node.kind === 'command' && node.name === 'compact'
? `c${node.commandId}`
: `n${node.seq}`,
node,
})
}
}
return items

View File

@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
@@ -217,14 +217,16 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Owner share of the per-command row slot: the frozen {@link CommandNode}
* slice off the snapshot (cache-stable reference — memo premise). The node
* carries the whole lifecycle (structured name/args, pairing id,
* outcome-or-executing), so a
* registrant needs no second data channel; domain state arrives through its
* own projection cell.
* carries the whole lifecycle (structured name/args, pairing id, and
* outcome-or-executing). A successful domain command may also carry the
* explicitly linked projection node needed to fold two log records into one
* presentation row.
*/
export interface CommandRowOwnerProps {
/** Folded command lifecycle node (run + optional done). */
node: CommandNode
/** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */
compaction?: CompactionSummaryNode
}
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */

View File

@@ -80,6 +80,8 @@ export const zh = {
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.running': '正在压缩…',
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
'message.unknownSurface': '未知 surface 事件:{type}',
@@ -220,6 +222,8 @@ export const en = {
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.running': 'Compacting context…',
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',
'message.unknownSurface': 'Unknown surface event: {type}',

View File

@@ -691,11 +691,15 @@ describe('MessageItem arms', () => {
<MessageItem t={t} node={{
kind: 'compaction', seq: 5, time: 1_000,
summary: '## 摘要标题\n\n保留的事实。',
summaryEventSeq: 4,
shadowedItemCount: 16,
shadowedTokenCount: 11_309,
}}
/>,
)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens')).toBeTruthy()
expect(view.queryByText(/保留的事实/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
@@ -705,7 +709,10 @@ describe('MessageItem arms', () => {
})
it('a marker whose provenance fell outside the window is not expandable', () => {
const view = render(<MessageItem t={t} node={{ kind: 'compaction', seq: 6, time: 1_000, summary: null }} />)
const view = render(<MessageItem t={t} node={{
kind: 'compaction', seq: 6, time: 1_000, summary: null,
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
}} />)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row).toHaveProperty('disabled', true)
expect(row.getAttribute('aria-expanded')).toBeNull()

View File

@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -93,6 +93,19 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
})
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummaryNode => ({
kind: 'compaction', seq: 8, time: 8_000,
summary: '## 压缩摘要\n\n保留的事实。',
summaryEventSeq: 7,
shadowedItemCount: 16,
shadowedTokenCount: 11_309,
...over,
})
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
@@ -212,6 +225,79 @@ describe('chat-flow derivation', () => {
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
})
it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => {
const running = command({
seq: 1,
commandId: 'cmd-compact' as CommandNode['commandId'],
name: 'compact',
outcome: null,
})
expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact')
const settled = {
...running,
outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 },
}
const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 })
const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint])
expect(flowKeys(items)).toBe('n0|n2|ccmd-compact')
expect(items.at(-1)).toEqual({
kind: 'command-compaction',
key: 'ccmd-compact',
command: settled,
compaction: checkpoint,
})
})
it('does not split adjacent tool results around a folded /compact command', () => {
const folded = command({
seq: 2,
commandId: 'cmd-compact' as CommandNode['commandId'],
name: 'compact',
outcome: { kind: 'success', sourceEventSeq: 4 },
})
const items = deriveChatFlow([
toolResult(1, 'a'),
folded,
toolResult(3, 'b'),
compaction({ seq: 5, summaryEventSeq: 4 }),
])
expect(flowKeys(items)).toBe('g1|ccmd-compact')
expect(
items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId),
).toEqual(['a', 'b'])
})
it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => {
const automatic = compaction({ seq: 2, summaryEventSeq: 1 })
expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2')
const first = command({
seq: 3,
commandId: 'cmd-a' as CommandNode['commandId'],
name: 'compact',
outcome: { kind: 'success', sourceEventSeq: 9 },
})
const second = command({
seq: 4,
commandId: 'cmd-b' as CommandNode['commandId'],
name: 'compact',
outcome: { kind: 'success', sourceEventSeq: 9 },
})
const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 })
expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10')
const sole = command({
seq: 11,
commandId: 'cmd-sole' as CommandNode['commandId'],
name: 'compact',
outcome: { kind: 'success', sourceEventSeq: 12 },
})
const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 })
const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 })
expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14')
})
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
@@ -1172,11 +1258,6 @@ describe('ChatView', () => {
})
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
const command = (over: Partial<CommandNode>): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the bare command name is the title, the outcome text
// the summary — neither the dispatched `/` nor its arguments reach the row
// (the settlement text already says what the command did).
@@ -1211,4 +1292,65 @@ describe('ChatView', () => {
expect(ov.getByText('命令')).toBeTruthy()
expect(ov.getByText('已完成')).toBeTruthy()
})
it('renders /compact as one stateful disclosure from running through completion', () => {
const running = command({
commandId: 'cmd-compact' as CommandNode['commandId'],
name: 'compact',
outcome: null,
})
const h = makeHarness({ nodes: [running] })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText('正在压缩…')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
act(() => {
h.set({
nodes: [{
...running,
outcome: {
kind: 'success',
text: 'Compacted 16 history items (~11309 tokens).',
sourceEventSeq: 7,
},
}, compaction()],
})
})
expect(view.queryByText('正在压缩…')).toBeNull()
expect(view.queryByText('上下文已压缩')).toBeNull()
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens')).toBeTruthy()
const row = view.getByRole('button', { name: /compact/ })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull()
expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull()
expect(view.queryByText('保留的事实。')).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull()
expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy()
})
it('keeps /compact no-history and error settlements on the generic command row', () => {
const noHistory = makeHarness({
nodes: [command({
name: 'compact',
outcome: { kind: 'success', text: 'No compactable history yet.' },
})],
})
const noHistoryView = render(<noHistory.ChatView {...noHistory.props} />)
expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy()
expect(noHistoryView.queryByRole('button')).toBeNull()
const failed = makeHarness({
nodes: [command({
commandId: 'cmd-compact-failed' as CommandNode['commandId'],
name: 'compact',
outcome: { kind: 'error', text: 'Compaction cancelled.' },
})],
})
const failedView = render(<failed.ChatView {...failed.props} />)
expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
})
})

View File

@@ -324,7 +324,10 @@ describe('deriveTrajectoryLayout', () => {
},
// A landed compaction renders no cell, but is still a real log position,
// so it moves the cursor after the visible context row.
{ kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' },
{
kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts',
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
},
{
kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
blocks: [{ kind: 'text', text: 'done' }],

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/compact/command-compact/README.md
README.md: a32a6aeb9957f0fd5f8cff58b1edbb9bc29a4e3d
README.zh.md: c678f522115d9b0fd414b2f290b3cb54ce690722
README.md: 54f341e39447a423964b7d7435cfb638857eda6e
README.zh.md: d4a122b8a19cdf907212ad019b2528ae52d03886

View File

@@ -12,7 +12,7 @@ Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The
| `/compact` with no compactable history | `No compactable history yet.` — no marker or surface mutation is written. |
| `/compact <anything>` | `Usage: /compact (no arguments)` — the command takes no arguments and calls no compaction backend. |
The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history.
The command is backend-independent: it depends only on `compactNow(agent, signal)`. The invoking agent is the exact target, and the dispatching UI's cancellation signal is forwarded through the seam. Every resolved invocation records the executor-owned log-only pair `command/run` / `command/done`; neither event joins model history. On success, `command/done.sourceEventSeq` names the transaction's `compact/summary` event so a presentation can fold the command lifecycle into its checkpoint without parsing result text or assuming adjacent rows.
Expected `ManualCompactionError` codes become stable direct errors:

View File

@@ -12,7 +12,7 @@
| `/compact`,但没有可压缩历史 | `No compactable history yet.`:不会写入标记,也不会变更 surface。 |
| `/compact <anything>` | `Usage: /compact (no arguments)`:该命令不接受参数,也不会调用压缩后端。 |
该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent智能体就是操作的确切目标发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。
该命令与后端无关,只依赖 `compactNow(agent, signal)`。调用该命令的 agent智能体就是操作的确切目标发起分发的 UI 会通过 seam 转发取消信号。每次完成的调用都会记录执行器所属的纯日志事件对 `command/run` / `command/done`;两者都不进入模型历史。成功时,`command/done.sourceEventSeq` 会指明该事务的 `compact/summary` 事件,让呈现层无须解析结果文本或假定两行相邻,即可将命令生命周期归并到对应检查点中。
预期的 `ManualCompactionError` 代码会成为稳定的直接错误:

View File

@@ -68,6 +68,7 @@ async function executeCompact(
return {
kind: 'success',
text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`,
sourceEventSeq: result.summarySeq,
}
} catch (error: unknown) {
if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' }

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands'
import {
CompactService,
ManualCompactionError,
@@ -15,9 +15,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandCompact from '@deepseek-ai/dsh-command-compact'
const RESULT: CompactionResult = {
startSeq: 10,
summarySeq: 11,
endSeq: 13,
startSeq: 1,
summarySeq: 2,
endSeq: 3,
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: 1, end: 7 },
shadowedSeqs: [1, 3, 7],
@@ -49,10 +49,24 @@ class StubCompactService extends CompactService {
this.calls.push({ agent, signal })
if (this.operation !== undefined) return this.operation()
return this.failure === undefined
? Promise.resolve(this.result)
? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result))
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values.
: Promise.reject(this.failure)
}
private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult {
agent.session.append('compact/start', { turn: null })
agent.session.append('compact/summary', {
summary: result.summary,
shadowedRange: result.shadowedRange,
shadowedSeqs: result.shadowedSeqs,
shadowedTokenCount: result.shadowedTokenCount,
provider: 'command-test',
model: 'command-test',
})
agent.session.append('compact/end', { turn: null })
return result
}
}
interface Harness {
@@ -91,9 +105,11 @@ async function run(
function expectLastLifecycle(
test: Harness,
args: string,
outcome: { readonly kind: 'success' | 'error'; readonly text?: string },
outcome: CommandResult,
): string {
const lifecycle = test.agent.session.events.slice(-2)
const lifecycle = test.agent.session.events
.filter(event => event.type === 'command/run' || event.type === 'command/done')
.slice(-2)
const runEvent = lifecycle[0]
const doneEvent = lifecycle[1]
if (runEvent?.type !== 'command/run' || doneEvent?.type !== 'command/done') {
@@ -149,6 +165,7 @@ describe('/compact human command', () => {
expect(execution.result).toEqual({
kind: 'success',
text: 'Compacted 3 history items (~42 tokens).',
sourceEventSeq: RESULT.summarySeq,
})
expect(execution.commandId).toBe(expectLastLifecycle(test, '', execution.result))
expect(test.compact.calls).toEqual([{ agent: test.agent, signal: controller.signal }])

View File

@@ -21,7 +21,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
const RESULT: CompactionResult = {
startSeq: 1,
summarySeq: 2,
endSeq: 4,
endSeq: 3,
summary: [{ type: 'text', text: 'loader summary' }],
shadowedRange: { start: 3, end: 8 },
shadowedSeqs: [3, 5, 8],
@@ -42,9 +42,19 @@ class LoaderCompactService extends CompactService {
}
override compactNow(
_agent: ManualCompactAgentContext,
agent: ManualCompactAgentContext,
_signal: AbortSignal,
): Promise<CompactionResult | null> {
agent.session.append('compact/start', { turn: null })
agent.session.append('compact/summary', {
summary: RESULT.summary,
shadowedRange: RESULT.shadowedRange,
shadowedSeqs: RESULT.shadowedSeqs,
shadowedTokenCount: RESULT.shadowedTokenCount,
provider: 'loader-test',
model: 'loader-test',
})
agent.session.append('compact/end', { turn: null })
return Promise.resolve(RESULT)
}
}
@@ -108,6 +118,7 @@ describe('command-compact real Loader composition', () => {
expect(execution.result).toEqual({
kind: 'success',
text: 'Compacted 3 history items (~99 tokens).',
sourceEventSeq: RESULT.summarySeq,
})
expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([
{
@@ -119,12 +130,32 @@ describe('command-compact real Loader composition', () => {
source: { kind: 'user' },
},
},
{
type: 'compact/start',
data: { turn: null },
},
{
type: 'compact/summary',
data: {
summary: RESULT.summary,
shadowedRange: RESULT.shadowedRange,
shadowedSeqs: RESULT.shadowedSeqs,
shadowedTokenCount: RESULT.shadowedTokenCount,
provider: 'loader-test',
model: 'loader-test',
},
},
{
type: 'compact/end',
data: { turn: null },
},
{
type: 'command/done',
data: {
commandId: execution.commandId,
kind: 'success',
text: 'Compacted 3 history items (~99 tokens).',
sourceEventSeq: RESULT.summarySeq,
},
},
])

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/compact/compact-basic/README.md
README.md: 0c7b009255dc2d41dc81cf2c7ff745e02ef28b9a
README.zh.md: 4af584a059c99725882afd6206bdf9c984c7d4e3
README.md: 4241899788998a744801bb0406a15ecd70af6401
README.zh.md: c1df4afaa1837a3b689da80cc1b49df43f60e513

View File

@@ -21,7 +21,7 @@ This backend owns the compaction policy:
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`); the transaction preserves those fields on `compact/summary`.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()` and requires complete `rawOutput`, while unmarked `rawOutput` does not identify the call path. The transaction preserves those fields on `compact/summary`.
## Config (`BasicCompactConfig`)

View File

@@ -21,7 +21,7 @@
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`);事务会在 `compact/summary` 上保留这些字段。
受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }``llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,且必须提供完整的 `rawOutput`;未带标记的 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
## 配置(`BasicCompactConfig`

View File

@@ -43,7 +43,7 @@ interface PreparedCompaction extends SurfaceSelection {
readonly input: SummarizationInput
}
interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
type SummarizedCompaction = PreparedCompaction & SummaryResult & {
readonly checkpointMessage: UserMessage
}
@@ -415,16 +415,18 @@ function commitCompactionBody(
shadowedSeqs,
shadowedTokenCount,
summary,
rawOutput,
provider,
model,
maxTokens,
usage,
checkpointMessage,
} = summarized
const callProvenance = summarized.llmStreamCall === true
? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
: summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
...callProvenance,
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,

View File

@@ -85,16 +85,27 @@ export interface SummarizationInput {
}
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
export interface SummaryResult {
export type SummaryResult = {
summary: ContentBlock[]
/** Complete provider output before the text-only summary projection. */
rawOutput?: ContentBlock[]
provider: string
model: string
maxTokens?: number
/** Provider-reported usage for this summarization request. */
usage?: TokenUsage
}
} & (
| {
/** Complete provider output before the text-only summary projection. */
rawOutput: ContentBlock[]
/** Identifies exactly one call through this context's `ctx.llm.stream()`. */
llmStreamCall: true
}
| {
/** Optional complete output from an unmarked template, remote, or other summarizer. */
rawOutput?: ContentBlock[]
/** An unmarked result does not identify a call through this context's LLM seam. */
llmStreamCall?: never
}
)
/**
* Run the default cache-reusing `ctx.llm.stream()` summarization call: replay
@@ -162,6 +173,7 @@ export async function summarizeWithLlm(
return {
summary,
rawOutput,
llmStreamCall: true,
provider: options.provider,
model: options.model,
maxTokens: config.maxTokens,

View File

@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import {
resolveCompactSpec,
@@ -868,6 +868,7 @@ describe('compaction region transaction', () => {
rawOutput: compact.rawOutput,
usage: compact.usage,
})
expect(summary?.data).not.toHaveProperty('llmStreamCall')
const head = session.deriveMessages()[0]!
expect(head.content[0]?.type).toBe('text')
expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
@@ -1165,6 +1166,15 @@ async function summarizerHarness(
}
describe('default one-shot summarizer', () => {
it('requires complete raw output when a subclass marks one local LLM stream call', () => {
expectTypeOf<{
summary: ContentBlock[]
llmStreamCall: true
provider: string
model: string
}>().not.toExtend<SummaryResult>()
})
it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
const { adapter, compact } = await summarizerHarness([
{ type: 'reasoning', text: 'private' },
@@ -1187,6 +1197,7 @@ describe('default one-shot summarizer', () => {
{ type: 'text', text: 'public summary' },
{ type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
],
llmStreamCall: true,
provider: MODEL,
model: MODEL,
maxTokens: 321,
@@ -1300,6 +1311,7 @@ describe('default one-shot summarizer', () => {
await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL)
expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({
summary: [{ type: 'text', text: 'routed summary' }],
llmStreamCall: true,
provider: 'routed-summary-provider',
model: 'routed-summary-model',
})

View File

@@ -28,8 +28,6 @@ declare module '@deepseek-ai/dsh-session' {
*/
'compact/summary': {
summary: ContentBlock[]
/** Complete provider output before the backend's safe summary projection. */
rawOutput?: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
@@ -46,7 +44,20 @@ declare module '@deepseek-ai/dsh-session' {
maxTokens?: number
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
}
} & (
| {
/** Complete provider output before the backend's safe summary projection. */
rawOutput: ContentBlock[]
/** Identifies exactly one call through this context's `ctx.llm.stream()`. */
llmStreamCall: true
}
| {
/** Optional complete output from an unmarked template, remote, or other summarizer. */
rawOutput?: ContentBlock[]
/** An unmarked summary does not identify a call through this context's LLM seam. */
llmStreamCall?: never
}
)
/**
* Marks the end of a compaction — log-only, releases the lock. Its owner
* matches `compact/start`; `error` records an unsuccessful attempt.

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/workspace-context/README.md
README.md: 82aee27a8fbd6a1ab0f0860226b081e28ba72e6e
README.zh.md: 9d983f95f4e018cb8fe983d9862cf5f31f83c5f2
README.md: 7ab21bbf8c72f8424bc8d4fdad9153c7ed8bb7e9
README.zh.md: 7ad68759ab811cdc5e848fd686c7fad612c9b6d9

View File

@@ -8,7 +8,7 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
The first eligible `agent/pre-step` of each live session composes the baseline. When the downstream decision enters a nonempty first-step batch, the plugin folds the baseline into that final batch right after the claimed prompt, so the direct prompt and the durable baseline enter step 1 and reach the first request together. A rejected or empty first-step decision leaves the baseline in the agent's `next-step` inbox for a later wakeup. The loader reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. If a previously queued workspace context is still pending, the plugin removes and replaces that exact inbox item instead of accumulating duplicates. A resumed session retains one compatible visible baseline and appends only current-file transitions; a changed discovery, precedence, project-root, or budget identity instead folds one explicitly superseding complete baseline into the entering batch.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
The plugin also observes immutable `tools/result` outcomes for successful first-party `read`, `write`, and `edit` calls. Each accepted touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file queues an addition in the agent inbox; a changed file queues a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate queues a removal notice. Native calls and Code Mode sub-dispatches share this path: nested touches bubble through opaque parent execution tokens until the top-level result settles, and touches produced inside an agent-loop step do not begin their asynchronous projection until the durable `step/end`. Direct tool executions outside an open step project immediately. This preserves tool-call/result/step adjacency without depending on filesystem timing. Discovery follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
@@ -48,7 +48,7 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
## State And Refresh
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step folds newly composed context into its final batch immediately after the claimed messages and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. If the owning `step/end` arrives before a matching dynamic context reaches the log, the plugin clears that pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
#### Token effect
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result.
Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result and its enclosing durable step.
#### KV Cache effect

View File

@@ -8,7 +8,7 @@
每个实时会话第一次符合条件的 `agent/pre-step` 会组合基线。当下游决策让非空的第一步批次进入时,插件会将基线折入最终批次、紧随已领取的直接提示词之后,使直接提示词与持久基线一同进入步骤 1并共同抵达第一次请求。reject 或空的第一步决策会将基线留在 agent 的 `next-step` inbox等待后续唤醒。loader 先读取 `$DSH_HOME/AGENTS.md`,随后针对项目根目录到 `agent.session.header.cwd` 的每个目录,先读取每个现有基础候选文件,再读取每个现有本地 overlay 候选文件。同一目录中,如果候选文件在去除首尾空白后字节完全一致,就会按已配置顺序折叠到最早候选文件,因此 `CLAUDE.md` 若只是复制同级 `AGENTS.md`,只会渲染一次。若之前排队的 workspace 上下文仍在等待,插件会删除并替换该确切 inbox 条目,而不会不断累积副本。恢复后的会话会保留一条兼容的可见基线,并只追加当前文件的转换;如果发现、优先级、项目根目录或预算标识发生变化,则会将一条明确取代旧基线的完整基线折入进入步骤的批次。
该插件还会监听 `tools/post-execute` 中成功的第一方 `read``write``edit` 调用。每次 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope新出现的文件通过结果的 `additionalContexts` 附加;已改变文件追加替换;文件消失或成为同一目录中较早候选文件的重复项时,追加移除通知。原生调用与 Code Mode 子分派共享该路径:`run_code` 将每个嵌套上下文延迟到外层结果,因此 loop 仍会在工具调用/结果相邻关系完成后追加更新。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell解析任意 shell 语法也不可靠。
该插件还会观察第一方 `read``write``edit` 调用成功后产生的不可变 `tools/result`。每个已接受的 touch 都会检查新达到的后代 scope 以及之前加载的每个 scope。每个已配置候选名称都是所在目录中的独立 scope新出现的文件会在 agent inbox 中排入一项新增;已改变文件会排入一项替换;文件消失或成为同一目录中较早候选文件的重复项时,会排入一则移除通知。原生调用与 Code Mode 子分派共享该路径:嵌套 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定;在 agent loop 步骤内产生的 touch须等持久 `step/end` 后才开始异步投影。打开的步骤之外直接执行工具时,则立即投影。这样无需依赖文件系统时序,也能保持工具调用/结果/步骤的相邻关系。这种发现跟随结构化文件系统活动,而不是 shell `cd`,因为每次本地 bash 调用都启动新 shell解析任意 shell 语法也不可靠。
指令读取使用可选 `ctx.fs` 提供方。该插件不会静态注入 `fs`,因此没有提供方的产品树仍可启动,指令加载在提供方出现前不执行任何操作。它会解析每个候选文件并对解析结果执行 stat因此会跟随路径最后一段的 symlink 到其目标指向常规文件的链接会加载目标内容缺失路径或非文件目标包括指向目录的链接则已确认不存在。resolve 或 stat 异常会改为将该候选文件的 scope 标记为暂时不可用。前缀取消与动态工具取消会传播到解析、元数据探测与流式读取。文件加载后的提供方失败会视为暂时不可用,而非文件已删除的证据。
@@ -48,7 +48,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
## 状态与刷新
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。每次相关工具 touch 时,插件会从可见会话事件重建已加载状态,并叠加一个短暂内存 pending 窗口,用于不可变顶层 `tools/result` 上存在但 loop 尚未追加的上下文。如果所属 `step/end` 在匹配的动态上下文进入日志之前到达,插件会清除该 pending 转换及其版本快速路径,使下一次成功 touch 可以重新加载。嵌套 Code Mode 结果会在外层执行 token 下暂存 pending 变更,用于抑制同次运行中的重复项;外层结果会回滚该状态,再只重新提交经过外层策略的上下文。
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本reject 则让当前上下文继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1也是每目录重复 key因此较早候选文件与某个未更改文件的内容收敛后后者仍可被移除。恢复可行因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩compaction会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone因此候选文件之后重新出现时会重新加载。只有在字节预算内实际渲染的模型可见变更才会进入来源、pending 状态和版本 cache已省略变更仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。
@@ -129,7 +129,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
#### Token 影响
每个已发现 scope 都会添加有界历史 token直到压缩。可见会话状态与版本digest 比较会抑制未更改内容Code Mode 将同一消息延迟外层 `run_code` 结果之后。
每个已发现 scope 都会添加有界历史 token直到压缩。可见会话状态与版本digest 比较会抑制未更改内容Code Mode 将同一消息延迟外层 `run_code` 结果及其所属持久步骤之后。
#### KV Cache 影响

View File

@@ -14,7 +14,7 @@ import { isDeepStrictEqual } from 'node:util'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
import {
@@ -85,15 +85,22 @@ export function apply(ctx: Context, config: Config): void {
excludedScopes: ReadonlySet<string>
}>()
const projectionLifecycle = new AbortController()
type ProjectionTouch = { agent: Agent; path: string }
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
ctx.effect(
() => () => {
projectionLifecycle.abort(new Error('workspace-context disposed'))
executionTouches.clear()
},
'workspace-context.projectionLifecycle',
)
// Emit listeners are not awaited, so each projection must compose against the
// inbox produced by earlier file results for the same agent.
const projectionTails = new WeakMap<Agent, Promise<void>>()
// Execution ancestry and the enclosing durable step are the two commit
// boundaries before an asynchronous projection may mutate the agent inbox.
const openSteps = new WeakMap<Session, boolean>()
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
const compose = async (
agent: Agent,
@@ -272,6 +279,46 @@ export function apply(ctx: Context, config: Config): void {
while ((projection = projectionTails.get(agent)) !== undefined) await projection
}
const stepIsOpen = (session: Session): boolean => {
const known = openSteps.get(session)
if (known !== undefined) return known
let open = false
for (const event of session.events) {
if (event.type === 'step/start') open = true
else if (event.type === 'step/end' || event.type === 'turn/end') open = false
}
openSteps.set(session, open)
return open
}
const projectTouch = (touch: ProjectionTouch): void => {
const session = touch.agent.session
if (!stepIsOpen(session)) {
queueProjection(touch.agent, touch.path)
return
}
const pending = stepTouches.get(session)
if (pending === undefined) stepTouches.set(session, [touch])
else pending.push(touch)
}
ctx.on('session/event', (session, event) => {
if (event.type === 'step/start') {
openSteps.set(session, true)
return
}
if (event.type === 'turn/end') {
openSteps.set(session, false)
return
}
if (event.type !== 'step/end') return
openSteps.set(session, false)
const pending = stepTouches.get(session)
if (pending === undefined) return
stepTouches.delete(session)
for (const touch of pending) queueProjection(touch.agent, touch.path)
})
ctx.on('agent/pre-step', async (
{ agent, messages, step, signal },
next,
@@ -301,9 +348,20 @@ export function apply(ctx: Context, config: Config): void {
})
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
const ownPath = filePathFromExecution(exec)
if (ownPath === undefined) return
queueProjection(exec.agent, ownPath)
const touches = executionTouches.get(exec.token) ?? []
executionTouches.delete(exec.token)
if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) {
const ownPath = filePathFromExecution(exec)
if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath })
}
if (exec.parent !== undefined) {
if (touches.length > 0) {
const parentTouches = executionTouches.get(exec.parent)
if (parentTouches === undefined) executionTouches.set(exec.parent, touches)
else parentTouches.push(...touches)
}
return
}
for (const touch of touches) projectTouch(touch)
})
}

View File

@@ -187,9 +187,11 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
}
}
function stubToolExecution(input: Omit<ToolExecution, 'token'>): ToolExecution {
function stubToolExecution(
input: Omit<ToolExecution, 'token'> & { token?: ToolExecutionToken },
): ToolExecution {
return {
token: Symbol('workspace-context-test-execution') as ToolExecutionToken,
token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken,
...input,
}
}
@@ -3948,6 +3950,106 @@ describe('dynamic nested workspace context injection', () => {
}
})
it('defers a nested file projection until the enclosing step commits', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
const agent = stubAgent(root)
const turnStart = agent.session.append('turn/start', { turn: 1 })
ctx.emit('session/event', agent.session, turnStart)
const stepStart = agent.session.append('step/start', { turn: 1, step: 1 })
ctx.emit('session/event', agent.session, stepStart)
const outerToken = Symbol('outer-code-run') as ToolExecutionToken
ctx.emit('tools/result', stubToolExecution({
token: Symbol('nested-read') as ToolExecutionToken,
parent: outerToken,
signal: testToolSignal,
callId: CallId('nested-read'),
name: 'read',
arguments: { file_path: join('pkg', 'file.txt') },
agent,
}), { content: [], isError: false, value: null })
ctx.emit('tools/result', stubToolExecution({
token: Symbol('nested-non-file') as ToolExecutionToken,
parent: outerToken,
signal: testToolSignal,
callId: CallId('nested-non-file'),
name: 'search',
arguments: {},
agent,
}), { content: [], isError: false, value: null })
ctx.emit('tools/result', stubToolExecution({
token: Symbol('second-nested-read') as ToolExecutionToken,
parent: outerToken,
signal: testToolSignal,
callId: CallId('second-nested-read'),
name: 'read',
arguments: { file_path: join('pkg', 'second.txt') },
agent,
}), { content: [], isError: false, value: null })
ctx.emit('tools/result', stubToolExecution({
token: outerToken,
signal: testToolSignal,
callId: CallId('outer-code-run'),
name: 'run_code',
arguments: {},
agent,
}), { content: [], isError: false, value: null })
await syncWorkspaceContext(ctx, agent)
expect(agent.inbox.nextStep).toEqual([])
const stepEnd = agent.session.append('step/end', { turn: 1, step: 1 })
ctx.emit('session/event', agent.session, stepEnd)
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
.toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('seeds closed step state from existing session history', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
const agent = stubAgent(root)
agent.session.append('turn/start', { turn: 1 })
agent.session.append('step/start', { turn: 1, step: 1 })
agent.session.append('step/end', { turn: 1, step: 1 })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('read-after-closed-step'),
name: 'read',
arguments: { file_path: join('pkg', 'file.txt') },
agent,
}), { content: [], isError: false, value: null })
expect(blocksText((await syncedWorkspaceContext(ctx, agent)).content))
.toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
})
it('ignores failed, aborted, agentless, and non-file final results', async () => {
const ctx = new Context()
try {

View File

@@ -1793,7 +1793,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CommandResult',
declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};',
declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: number;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};',
},
{
name: 'CompactAgentContext',

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/examples/README.md
README.md: 64fff8cb3f53386d48a9a831c1cbdd946ad483cc
README.zh.md: c346a41d297a545991a2441df625286e1b830998
README.md: 2d672dcc307bb280cf3803f29128eba4988a8da0
README.zh.md: e827e7cff4ff9d6521e5889e48270e06641ef38c

View File

@@ -7,11 +7,10 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| [`agent-spine-demo/`](agent-spine-demo/README.md) | `@deepseek-ai/dsh-agent-spine-demo` | Reusable agent-spine bundle |
| [`cli-demo/`](cli-demo/README.md) | `@deepseek-ai/dsh-cli-demo` | Headless one-shot application bundle |
| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle |
| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime |
`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` add their front doors, while `jsonrpc-demo` boots a deployment-owned plugin tree.
`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it.
These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions.

View File

@@ -7,11 +7,10 @@
| 包 | npm 名称 | 角色 |
|---|---|---|
| [`agent-spine-demo/`](agent-spine-demo/README.md) | `@deepseek-ai/dsh-agent-spine-demo` | 可复用的 agent 主干组合包 |
| [`cli-demo/`](cli-demo/README.md) | `@deepseek-ai/dsh-cli-demo` | 无头单次应用组合包 |
| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP 自动化应用组合包 |
| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 |
`agent-spine-demo` 是共享组合包;`cli-demo``acp-demo` 添加各自的前端入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。
`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行归 `dsh run` 所有;本目录没有任何包提供该功能。
这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包只选择具体组合。

View File

@@ -84,7 +84,7 @@
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"node-addon-landlock-run": "0.0.0-test.0",
"@deepseek-ai/node-addon-landlock-run": "workspace:*",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {

View File

@@ -15,7 +15,7 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import type { ToolResult } from '@deepseek-ai/dsh-tools'
import { launcherPath } from 'node-addon-landlock-run'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import * as agentSpine from '../src/index.ts'
const bwrapUsable = spawnSync('bwrap', [

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../native/landlock-run/packages/entry"
},
{
"path": "../../llm/llm"
},

View File

@@ -1,78 +0,0 @@
# @deepseek-ai/dsh-cli-demo
English | [中文](README.zh.md)
Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin owns one idle-to-idle activity interval, renders its selected output, disposes to quiescence, and exits.
The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr.
## Config
| Key | Default | Routed to |
|---|---|---|
| `provider` | required | the configured agent's provider route |
| `model` | required | the configured agent's model |
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap; `1` is serial |
| `persona` | — | the deployment persona in `dsh-system-prompt` |
| `toolOrder` | lexicographic | explicit model-facing tool order in `dsh-system-prompt` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `dsh-agent-spine-demo` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `sessionTitle` | spine example limits | Fallback title word/byte limits through `dsh-agent-spine-demo` |
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in |
| `toolTasks` | owner defaults | generic `task_output` wait bounds |
| `persistenceRoot` | `./.sessions` | JSONL session root |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading |
## CLI contract
```sh
dsh-cli-demo [--config path] [--output-format text|json|stream-json] <task>
```
`--config` defaults to `./cordis.yml`; `--output-format` defaults to `text`. Exactly one nonblank positional task is required, so quote tasks containing spaces. `--help` prints usage without booting. There is no `-p` or `--print` flag.
The root headless-agent example supplies its leaf:
```sh
pnpm run demo:headless "inspect the failing test and fix it"
```
Loader configs resolve bare package specifiers through the optional native helper installed by the repository, so the root command needs no special Node flags.
### Output formats
- `text` writes the last assistant message containing text, followed by one newline.
- `json` writes one DSH-native result record: `{ type: "result", sessionId, output, usage? }`. `output` is the last committed assistant text in the activity interval. `usage` sums each model step in that interval once, including billed failed attempts that produced usage without a committed assistant message.
- `stream-json` writes each canonical event from the top-level session's owned activity interval as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results.
Normal idle completion exits successfully without assigning a turn reason to the task. Argument, boot, observation, and persistence failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively.
The owned activity is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits.
## Operational safety
The headless-agent leaf supplies local bash, filesystem, skill, subagent, workflow, and todo capabilities. A task can therefore mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. Run the CLI from the intended project directory, review the leaf's capability and sandbox configuration, and do not treat non-interactive execution as an approval boundary.
## Model Experience
### One-shot activity
#### What the model sees
The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the owned activity.
#### Token effect
The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total.
#### KV Cache effect
Tool-round history is append-only while the one-shot agent's prompt, schemas, model route, and session prefix remain fixed. Changing that composition establishes a different request prefix; JSON output mode has no cache effect.
## Known Limitations and Deferred Work
- **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app.
- **No interactive question or approval provider** — tools that require a human answer cannot complete unless a different leaf composes a non-interactive provider with explicit policy.
- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent activity interval.

View File

@@ -1,78 +0,0 @@
# @deepseek-ai/dsh-cli-demo
[English](README.md) | 中文
无头单次应用及 bin用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent智能体任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。bin 拥有一个从 idle 到 idle 的活动区间,渲染所选输出,执行 dispose资源释放直至完全停稳然后退出。
该包不挂载 console logger、交互式 UI、用户交互服务或 `ask_user_question` 工具。Stdout 专用于所选输出格式;诊断使用 stderr。
## 配置
| 键 | 默认值 | 路由目标 |
|---|---|---|
| `provider` | 必填 | 已配置 agent 的提供方路由 |
| `model` | 必填 | 已配置 agent 的模型 |
| `maxParallelToolCalls` | agent loop 默认值 | 正整数并发工具调用上限;`1` 表示串行 |
| `persona` | 无 | `dsh-system-prompt` 中的部署 persona |
| `toolOrder` | 字典序 | `dsh-system-prompt` 中显式的面向模型工具顺序 |
| `tools` | `{ mode: 'native' }` | 通过 `dsh-agent-spine-demo` 提供的工具注册表呈现配置 |
| `dshHome` | `$DSH_HOME``~/.dsh` | 向模型 bash 公开并用于本地 skill技能发现的 harness 主目录 |
| `sessionTitle` | 主干示例限制 | 通过 `dsh-agent-spine-demo` 提供的后备标题词数/字节限制 |
| `skills` | 拥有者默认值 | skill 注册表、本地提供方和面向模型的 skill 工具 |
| `toolBash` | 拥有者默认值 | 面向模型的 bash 配置,包括此生产方对后台任务的显式启用 |
| `toolTasks` | 拥有者默认值 | 通用 `task_output` 等待边界 |
| `persistenceRoot` | `./.sessions` | JSONL 会话根目录 |
| `persistenceCompression` | `'zstd'` | JSONL 产物编码(`'zstd'` 或原始 `'none'` |
| `workspaceContext` | 必填 | 工作区指令字节预算,或以 `false` 禁用加载 |
## CLI命令行界面契约
```sh
dsh-cli-demo [--config path] [--output-format text|json|stream-json] <task>
```
`--config` 默认为 `./cordis.yml``--output-format` 默认为 `text`。必须恰好提供一个非空的任务位置参数,因此含空格的任务需要加引号。`--help` 在不启动的情况下打印用法。不存在 `-p``--print` 标志。
根 headless-agent 示例提供其叶节点:
```sh
pnpm run demo:headless "inspect the failing test and fix it"
```
loader 配置通过仓库安装的可选原生辅助程序解析裸包说明符,因此根命令不需要特殊 Node 标志。
### 输出格式
- `text` 写入最后一条含文本的 assistant 消息,后跟一个换行符。
- `json` 写入一条 DSH 原生结果记录:`{ type: "result", sessionId, output, usage? }``output` 是活动区间内最后提交的 assistant 文本。`usage` 对该区间中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败尝试。
- `stream-json` 将顶层会话自有活动区间中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。
正常进入 idle 会成功退出,不会为该任务指定轮次原因。参数、启动、观测和持久化失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消正在进行的工作,等待 dispose 完成,并分别以 130 和 143 退出。
自有活动会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。
## 操作安全
headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、工作流和 todo 能力。因此任务可以修改启动工作区、运行命令、spawn 子 agent并消耗提供方 token。请从目标项目目录运行 CLI检查叶节点的能力与沙箱配置不要把非交互式执行当作批准边界。
## 模型体验
### 单次活动
#### 模型看到的内容
任务位置参数会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的工作区指令与 persona、skill 目录、可见工具 schema以及自有活动后续步骤所需的保留工具结果。
#### Token 影响
每个模型步骤中的任务、提示词段、工具 schema、assistant 输出和工具结果都会消耗 token。JSON 事件流式输出和最终渲染不增加模型 token委派的子工作有自己的模型用量不计入父结果的 `usage` 总量。
#### KV Cache 影响
只要单次 agent 的提示词、schema、模型路由和会话前缀保持不变工具轮次历史就仅追加。改变该组合会建立不同的请求前缀JSON 输出模式不影响缓存。
## 已知限制与暂缓事项
- **每个进程只创建一个新的顶层会话**:其工作区 cwd 是启动目录此应用不支持恢复、第二条提示词、stdin 上下文或并发顶层会话。
- **没有交互式问题或批准提供方**:需要人工回答的工具无法完成,除非其他叶节点按显式策略组合一个非交互式提供方。
- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父活动区间记录的模型步骤。

View File

@@ -1,68 +0,0 @@
{
"name": "@deepseek-ai/dsh-cli-demo",
"description": "Headless one-shot agent app with text and DSH-native JSON output",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-cli-demo": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./bin": {
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
}
}

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env node
/**
* Process wrapper for `dsh-cli-demo`; covered parsing and task execution live in
* `cli.ts` while this entry owns Unix signal-to-exit-code mapping.
* @module @deepseek-ai/dsh-cli-demo/bin
*/
import { installFailLoud } from '@deepseek-ai/dsh-app-boot'
import { executeCli } from './cli.ts'
const NAME = 'dsh-cli-demo'
/* v8 ignore start -- thin self-executing process glue; built-bin tests exercise
real argv, signals, Loader boot, output, and exit codes */
const abort = new AbortController()
let signalExitCode: number | undefined
const interrupt = (signal: 'SIGINT' | 'SIGTERM', code: number): void => {
signalExitCode ??= code
if (!abort.signal.aborted) abort.abort(`received ${signal}`)
}
const onSigint = (): void => { interrupt('SIGINT', 130) }
const onSigterm = (): void => { interrupt('SIGTERM', 143) }
const uninstallFailLoud = installFailLoud(NAME)
process.on('SIGINT', onSigint)
process.on('SIGTERM', onSigterm)
try {
const code = await executeCli(process.argv.slice(2), { signal: abort.signal })
process.exitCode = signalExitCode ?? code
} finally {
process.off('SIGINT', onSigint)
process.off('SIGTERM', onSigterm)
uninstallFailLoud()
}
/* v8 ignore stop */

View File

@@ -1,406 +0,0 @@
/**
* Command parser and one-turn driver for `dsh-cli-demo`. The executable wrapper
* owns process signals; this module owns output, durability, and cleanup.
* @module @deepseek-ai/dsh-cli-demo/cli
*/
import { parseArgs } from 'node:util'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const CLI_NAME = 'dsh-cli-demo'
const DEFAULT_CONFIG_PATH = './cordis.yml'
const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] (-p <task> | <task>)\n`
/** Supported CLI output encodings. */
export type OutputFormat = typeof OUTPUT_FORMATS[number]
/** Parsed command: help exits before boot; run carries one validated task. */
export type CliCommand =
| { readonly kind: 'help' }
| {
readonly kind: 'run'
readonly configPath: string
readonly outputFormat: OutputFormat
readonly task: string
}
/** DSH-native final record emitted by JSON modes. */
export interface CliResult {
readonly type: 'result'
readonly sessionId: string
readonly output: string
readonly usage?: TokenUsage
}
/** Options for one turn against the configured top-level agent. */
export interface OneShotOptions {
/** Exactly one nonblank user task. */
readonly task: string
/** Optional signal that cancels the selected agent. */
readonly signal?: AbortSignal
/** Synchronous task-turn observer; a throw cancels the agent and fails the run after flush. */
readonly onEvent?: (sessionId: string, event: SessionEvent) => void
}
/** Injectable process boundaries used by {@link executeCli}. */
export interface CliRuntime {
/** Process cwd for config resolution and `.env` loading. */
readonly cwd?: string
/** Cancellation signal, normally aborted by SIGINT or SIGTERM. */
readonly signal?: AbortSignal
/** Loader boot boundary. */
readonly boot?: (name: string, absoluteConfigPath: string) => Promise<Context>
/** Optional `.env` loader boundary. */
readonly loadEnv?: (name: string, dir: string, warn: (line: string) => void) => void
/** Stdout sink; throws are treated as output failures. */
readonly writeStdout?: (chunk: string) => unknown
/** Stderr diagnostic sink. */
readonly writeStderr?: (chunk: string) => unknown
/** Context disposal boundary. */
readonly dispose?: (ctx: Context) => Promise<void>
}
interface ParsedArguments {
readonly values: {
readonly config?: string
readonly 'output-format'?: string
readonly help?: boolean
readonly prompt?: string
}
readonly positionals: string[]
}
class CliArgumentError extends Error {
constructor(message: string) {
super(message)
this.name = 'CliArgumentError'
}
}
class CliInterruptedError extends Error {
constructor(reason: string) {
super(reason)
this.name = 'CliInterruptedError'
}
}
/** Render an arbitrary value without trusting its type traps or string coercion. */
function renderUnknown(value: unknown): string {
try {
return String(value)
} catch {
return '[unrenderable thrown value]'
}
}
/** Normalize an arbitrary thrown value without letting inspection escape containment. */
function toError(error: unknown): Error {
try {
if (error instanceof Error) return error
} catch {
// A hostile proxy may throw during instanceof; use the total renderer below.
}
return new Error(renderUnknown(error))
}
function interruptionReason(signal: AbortSignal): string {
return signal.reason === undefined ? 'interrupted' : renderUnknown(signal.reason)
}
/**
* Parse the bin arguments and enforce the one-positional-task contract.
* @param args - arguments after the executable name.
* @returns a help or run command.
* @throws {@link CliArgumentError} for unknown flags, invalid formats, or task cardinality.
*/
export function parseCliArgs(args: readonly string[]): CliCommand {
let parsed: ParsedArguments
try {
parsed = parseArgs({
args: [...args],
options: {
config: { type: 'string' },
'output-format': { type: 'string' },
help: { type: 'boolean' },
prompt: { type: 'string', short: 'p' },
},
allowPositionals: true,
strict: true,
})
} catch (error: unknown) {
throw new CliArgumentError(toError(error).message)
}
if (parsed.values.help === true) return { kind: 'help' }
const prompt = parsed.values.prompt
if (prompt !== undefined && parsed.positionals.length > 0) {
throw new CliArgumentError('-p/--prompt and a positional task are mutually exclusive')
}
if (prompt === undefined && parsed.positionals.length !== 1) {
throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`)
}
// Cardinality was checked above, so the fallback index zero exists.
// oxlint-disable-next-line typescript/no-non-null-assertion
const task = prompt ?? parsed.positionals[0]!
if (task.trim().length === 0) throw new CliArgumentError('task must not be blank')
const requestedFormat = parsed.values['output-format'] ?? 'text'
if (!OUTPUT_FORMATS.some(format => format === requestedFormat)) {
throw new CliArgumentError(`unsupported output format ${JSON.stringify(requestedFormat)}`)
}
return {
kind: 'run',
configPath: parsed.values.config ?? DEFAULT_CONFIG_PATH,
outputFormat: requestedFormat as OutputFormat,
task,
}
}
function addUsage(total: TokenUsage | undefined, step: TokenUsage): TokenUsage {
const next: TokenUsage = {
inputTokens: (total?.inputTokens ?? 0) + step.inputTokens,
outputTokens: (total?.outputTokens ?? 0) + step.outputTokens,
}
for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'] as const) {
if (total?.[key] !== undefined || step[key] !== undefined) next[key] = (total?.[key] ?? 0) + (step[key] ?? 0)
}
return next
}
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string | undefined {
const blocks = event.data.message.content.filter(block => block.type === 'text')
return blocks.length === 0 ? undefined : blocks.map(block => block.text).join('')
}
/** Wait for startup quiescence while making pre-run cancellation terminal. */
async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise<void> {
if (signal === undefined) {
await agent.whenIdle()
return
}
if (signal.aborted) {
agent.cancel({ kind: 'user' })
throw new CliInterruptedError(interruptionReason(signal))
}
await new Promise<void>((resolve, reject) => {
const onAbort = (): void => {
agent.cancel({ kind: 'user' })
reject(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
void agent.whenIdle().then(resolve, reject).finally(() => {
signal.removeEventListener('abort', onAbort)
})
})
}
/**
* Run one owned activity interval on the configured top-level agent, from the
* task's durable enqueue receipt through whole-agent idle.
* @param ctx - settled Loader root containing one agent plus `ctx.sessions`.
* @param options - task, optional cancellation, and optional stream observer.
* @returns the DSH-native result envelope after durable quiescence.
*/
export async function runOneShot(ctx: Context, options: OneShotOptions): Promise<CliResult> {
const agents = ctx.get('agents')?.roots() ?? []
const [agent] = agents
if (agent === undefined || agents.length !== 1) {
throw new Error(`config must create exactly one top-level agent, found ${agents.length}`)
}
await waitForStartupIdle(agent, options.signal)
const message = createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })
let received = false
let output = ''
const usageByStep = new Map<string, TokenUsage>()
let outputError: Error | undefined
let interrupted: CliInterruptedError | undefined
const observe = (sessionId: string, event: SessionEvent): void => {
if (outputError !== undefined || options.onEvent === undefined) return
try {
options.onEvent(sessionId, event)
} catch (error: unknown) {
outputError = toError(error)
queueMicrotask(() => {
agent.cancel({ kind: 'user' })
})
}
}
const disposeListener = ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (!received) {
if (event.type !== 'agent/inbox/spliced'
|| !event.data.inserted.some(inserted => inserted.id === message.id)) return
received = true
}
observe(session.id, event)
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
}
if (event.type === 'assistant/message') {
output = assistantText(event) ?? output
if (event.data.usage !== undefined) {
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
}
}
})
const signal = options.signal
let onAbort: (() => void) | undefined
if (signal !== undefined) {
onAbort = (): void => {
interrupted ??= new CliInterruptedError(interruptionReason(signal))
agent.cancel({ kind: 'user' })
}
signal.addEventListener('abort', onAbort, { once: true })
/* v8 ignore next -- closes the race between startup-idle completion and listener registration */
if (signal.aborted) onAbort()
}
try {
if (interrupted === undefined) agent.followup(message)
await agent.whenIdle()
} finally {
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
disposeListener()
}
await ctx.sessions.flush(agent.session)
if (outputError !== undefined) throw outputError
if (interrupted !== undefined) throw interrupted
const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined)
return {
type: 'result',
sessionId: agent.session.id,
output,
...usage === undefined ? {} : { usage },
}
}
function renderResult(outputFormat: OutputFormat, result: CliResult): string {
return outputFormat === 'text' ? `${result.output}\n` : `${JSON.stringify(result)}\n`
}
/**
* Race Loader boot with cancellation without abandoning a context that becomes
* available after the caller has been released. Waiting for that late context
* would recreate the signal hang, so its disposal and diagnostics run detached.
*/
async function bootInterruptibly(
start: () => Promise<Context>,
signal: AbortSignal | undefined,
disposeLateContext: (ctx: Context) => Promise<void>,
reportLateDisposalFailure: (error: unknown) => void,
): Promise<Context> {
if (signal === undefined) return await start()
if (signal.aborted) throw new CliInterruptedError(interruptionReason(signal))
let onAbort!: () => void
const interruptedBoot = new Promise<never>((_resolve, reject) => {
onAbort = (): void => {
reject(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
/* v8 ignore next -- closes registration against a non-standard synchronously mutating signal */
if (signal.aborted) onAbort()
})
const booting = Promise.resolve().then(start)
try {
return await Promise.race([booting, interruptedBoot])
} catch (error: unknown) {
// The awaited race permits the signal to change after the preflight check.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (signal.aborted) {
void booting.then(
async (lateContext) => {
try {
await disposeLateContext(lateContext)
} catch (error: unknown) {
reportLateDisposalFailure(error)
}
},
() => {},
)
}
throw error
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/**
* Execute one CLI invocation. Argument and boot failures never write stdout;
* context disposal is awaited before return, and its failure does not replace
* an earlier diagnostic.
* @param args - arguments after the executable name.
* @param runtime - optional injected process boundaries for tests and embedding.
* @returns the ordinary process exit code; the thin bin overrides it for Unix signals.
*/
export async function executeCli(args: readonly string[], runtime: CliRuntime = {}): Promise<number> {
/* v8 ignore next -- default process sinks are exercised by the built-bin smoke */
const writeStdout = runtime.writeStdout ?? (chunk => process.stdout.write(chunk))
/* v8 ignore next -- default process sinks are exercised by the built-bin smoke */
const writeStderr = runtime.writeStderr ?? (chunk => process.stderr.write(chunk))
let command: CliCommand
try {
command = parseCliArgs(args)
} catch (error: unknown) {
writeStderr(`${CLI_NAME}: ${toError(error).message}\n${USAGE}`)
return 1
}
if (command.kind === 'help') {
writeStdout(USAGE)
return 0
}
/* v8 ignore next -- default process cwd is exercised by the built-bin smoke */
const cwd = runtime.cwd ?? process.cwd()
/* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */
const loadEnvironment = runtime.loadEnv ?? loadEnv
/* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */
const bootContext = runtime.boot ?? boot
/* v8 ignore next -- default disposal is exercised by the built-bin smoke */
const disposeContext = runtime.dispose ?? (target => target.fiber.dispose())
let ctx: Context | undefined
let exitCode = 1
let diagnostic: string | undefined
try {
loadEnvironment(CLI_NAME, cwd, line => writeStderr(line))
ctx = await bootInterruptibly(
() => bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd)),
runtime.signal,
disposeContext,
error => writeStderr(`${CLI_NAME}: dispose after interrupted boot failed: ${toError(error).message}\n`),
)
const result = await runOneShot(ctx, {
task: command.task,
...runtime.signal === undefined ? {} : { signal: runtime.signal },
...command.outputFormat === 'stream-json'
? { onEvent: (sessionId: string, event: SessionEvent) => {
writeStdout(`${JSON.stringify({ type: 'session_event', sessionId, event })}\n`)
} }
: {},
})
writeStdout(renderResult(command.outputFormat, result))
exitCode = 0
} catch (error: unknown) {
diagnostic = `${CLI_NAME}: ${toError(error).message}\n`
} finally {
if (ctx !== undefined) {
try {
await disposeContext(ctx)
} catch (error: unknown) {
diagnostic = `${diagnostic ?? ''}${CLI_NAME}: dispose failed: ${toError(error).message}\n`
exitCode = 1
}
}
}
if (diagnostic !== undefined) writeStderr(diagnostic)
return exitCode
}

View File

@@ -1,96 +0,0 @@
/**
* Headless one-shot app composition: the default agent spine, JSONL session
* persistence, and one fresh top-level agent. The CLI driver owns task
* submission and output; the app deliberately mounts no interactive or logging
* front door so stdout remains protocol-pure.
* @module @deepseek-ai/dsh-cli-demo
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { SessionId } from '@deepseek-ai/dsh-session'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
export const name = 'cli-demo'
/** App config forwarded to the spine, configured agent, and JSONL backend. */
export interface Config {
/** Provider route for the configured agent. */
provider: string
/** Model name for the configured agent; a matching adapter must be registered. */
model: string
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
maxParallelToolCalls?: number
/** Deployment persona forwarded to the system-prompt plugin. */
persona?: string
/** Explicit model-facing tool order forwarded to the system-prompt plugin. */
toolOrder?: string[]
/** Tool-registry presentation config forwarded through agent-spine-demo. */
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-spine-demo. */
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-spine-demo. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
// Each front door keeps a complete Loader schema so its deployment contract is
// readable without a cross-package config facade.
/* jscpd:ignore-start */
export const Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
persona: z.string(),
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
skills: agentCore.SkillConfigSchema,
// Absent means lexicographic order; schemastery's native array default is [].
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/* jscpd:ignore-end */
/**
* Compose the UI-less spine, a fresh top-level agent rooted at the process cwd,
* and JSONL persistence. Swappable adapters, executors, and product tools stay
* in the leaf `cordis.yml`.
* @param ctx - app context that owns the composed child plugins.
* @param config - validated app configuration.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore, {
...agentCore.pickSpineConfig(config),
agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }],
})
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(sessionCheckpointPolicy)
}

View File

@@ -1,223 +0,0 @@
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { zstdDecompress } from 'node:zlib'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
* The consumer's mock model is an example-local TypeScript plugin (Node 22.19+ — the engines
* floor — strips types natively, so plain `node` loads it), its config carries a `disabled:
* true` unresolvable entry (the fail-loud entry-load guard must not mistake an intentionally
* fiber-less entry for a failed import), and the optional spill pair loads from the consumer
* install — so every passing boot proves all three alongside the CLI's own output contract.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
const decompress = promisify(zstdDecompress)
const dshPackages = [
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy',
'session-persistence/session-persistence-jsonl',
'context/workspace-context',
'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention',
]
const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
async function packageName(dir: string): Promise<string> {
return (JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { name: string }).name
}
async function linkPackage(dir: string, nodeModules: string): Promise<void> {
const target = join(nodeModules, await packageName(dir))
await mkdir(dirname(target), { recursive: true })
await symlink(dir, target)
}
async function makeConsumer(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'cli-built-bin-'))
const nodeModules = join(dir, 'node_modules')
for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules)
for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules)
await writeFile(join(dir, 'mock-llm.ts'), [
// Real type annotations: this file exists to prove plain Node's type
// stripping loads an example-local TS plugin from a built consumer.
"import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'",
"import type { Context } from 'cordis'",
'class Mock extends LlmAdapter {',
' async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {',
" const text: string = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
" yield { type: 'block-start', index: 0, blockType: 'text' }",
" if (text === 'hang') {",
" yield { type: 'text-delta', index: 0, text: 'partial' }",
' await new Promise<never>((resolve, reject) => {',
" const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)",
" const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }",
' if (options.signal.aborted) onAbort()',
" else options.signal.addEventListener('abort', onAbort, { once: true })",
' })',
' return',
' }',
' const reply = `BUILT: ${text}`',
" yield { type: 'text-delta', index: 0, text: reply }",
" yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }",
" yield { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }",
" yield { type: 'finish', reason: { kind: 'stop' } }",
' }',
'}',
"export const name = 'built-cli-mock'",
"export const inject = ['llm']",
"export function apply(ctx: Context) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
'',
].join('\n'))
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.ts'",
'- id: subprocess',
" name: '@deepseek-ai/dsh-subprocess-local'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',
" name: '@deepseek-ai/dsh-cli-demo'",
' config:',
' provider: built-cli-mock',
' model: built-cli-mock',
" persona: 'built CLI test'",
" persistenceRoot: './.sessions'",
' workspaceContext: false',
'- id: spill-local',
" name: '@deepseek-ai/dsh-spill-local'",
'- id: spill-policy',
" name: '@deepseek-ai/dsh-spill-policy'",
' config:',
' maxInlineBytes: 50000',
// A `disabled: true` entry settles without a fiber by design; the fail-loud
// entry-load guard must not mistake it for a failed import. The nonexistent
// path makes that distinction observable while a clean run proves boot continued.
'- id: off',
" name: './does-not-exist.ts'",
' disabled: true',
'',
].join('\n'))
return dir
}
interface BinResult {
readonly code: number
readonly signal: NodeJS.Signals | null
readonly stdout: string
readonly stderr: string
}
async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise<BinResult> {
const subprocess = execa(process.execPath, [cliBin, ...args], {
cwd,
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdin: 'ignore',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
stripFinalNewline: false,
})
// Genuinely custom mid-stream logic: the signal cases deliver `interrupt`
// once the first streamed chunk proves the turn is in flight.
if (interrupt !== undefined) {
let streamed = ''
let interrupted = false
subprocess.stdout.on('data', (chunk: Buffer) => {
streamed += chunk.toString('utf8')
if (!interrupted && streamed.includes('assistant/chunk')) {
interrupted = true
subprocess.kill(interrupt)
}
})
}
const result = await subprocess
if (result.timedOut) {
throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
}
return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr }
}
let consumer: string | undefined
afterEach(async () => {
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
consumer = undefined
})
describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
it('runs text, json, and stream-json under plain Node and persists fresh sessions', async () => {
consumer = await makeConsumer()
const text = await runBuiltBin(consumer, ['--config', './cordis.yml', 'hello'])
expect(text).toMatchObject({ code: 0, signal: null, stdout: 'BUILT: hello\n', stderr: '' })
const json = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'json', 'json task'])
expect(JSON.parse(json.stdout)).toMatchObject({
type: 'result', output: 'BUILT: json task',
usage: { inputTokens: 4, outputTokens: 2 },
})
const stream = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'stream-json', 'stream task'])
const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(lines[0]).toMatchObject({
type: 'session_event',
event: {
type: 'agent/inbox/spliced',
data: {
target: 'next-turn',
start: 0,
inserted: [{ content: [{ type: 'text', text: 'stream task' }], source: { kind: 'user' } }],
},
},
})
expect(lines.findIndex(line =>
(line['event'] as { type?: string } | undefined)?.type === 'turn/start')).toBeGreaterThan(0)
expect(lines.at(-1)).toMatchObject({ type: 'result', output: 'BUILT: stream task' })
const sessionsRoot = join(consumer, '.sessions')
const files = await readdir(sessionsRoot, { recursive: true })
const logs = files.filter(file => file.endsWith('.jsonl.zstd'))
expect(logs).toHaveLength(3)
const compressed = await readFile(join(sessionsRoot, logs[0]!))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' })
}, 30_000)
it('keeps stdout empty for invalid argv and missing config', async () => {
consumer = await makeConsumer()
for (const args of [
['--config', './cordis.yml'],
['--config', './cordis.yml', 'one', 'two'],
['--config', './missing.yml', 'task'],
]) {
const result = await runBuiltBin(consumer, args)
expect(result.code).not.toBe(0)
expect(result.stdout).toBe('')
expect(result.stderr.length).toBeGreaterThan(0)
}
}, 30_000)
describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => {
it.each([
['SIGINT', 130],
['SIGTERM', 143],
] as const)('cancels and disposes on %s with exit %i', async (signal, code) => {
consumer = await makeConsumer()
const result = await runBuiltBin(
consumer,
['--config', './cordis.yml', '--output-format', 'stream-json', 'hang'],
signal,
)
expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null })
expect(result.stdout).toContain('"kind":"aborted"')
expect(result.stderr).toBe(`dsh-cli-demo: received ${signal}\n`)
}, 30_000)
})
})

View File

@@ -1,202 +0,0 @@
import { mkdtemp } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as cliDemo from '../src/index.ts'
const testToolSignal = new AbortController().signal
const contexts: Context[] = []
async function skillConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<cliDemo.Config['skills']>> {
const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-skills-'))
return {
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
...catalogDescriptionMaxLength === undefined ? {} : { tool: { catalogDescriptionMaxLength } },
}
}
async function mount(config: cliDemo.Config, withBash = false): Promise<Context> {
const ctx = new Context()
if (withBash) {
ctx.provide('bash', {
sandboxMode: undefined,
resolve() { throw new Error('composition test does not execute bash') },
run() { throw new Error('composition test does not execute bash') },
start() { throw new Error('composition test does not execute bash') },
})
}
contexts.push(ctx)
config.persistenceRoot ??= await mkdtemp(join(tmpdir(), 'dsh-cli-demo-persistence-'))
await ctx.plugin(cliDemo, config)
await new Promise(resolve => setTimeout(resolve, 80))
return ctx
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
return agent.session.deriveMessages()
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe('dsh-cli-demo app composition', () => {
it('composes the UI-less spine, JSONL persistence, and a main agent', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-compose-'))
const ctx = await mount({
provider: 'mock',
model: 'mock',
persona: 'Headless.',
tools: { mode: 'native' },
persistenceRoot: root,
persistenceCompression: 'none',
skills: await skillConfig(),
workspaceContext: false,
})
const [agent] = ctx.get('agents')?.roots() ?? []
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
expect(agent?.session.header.cwd).toBe(process.cwd())
expect(ctx.get('userInteraction')).toBeUndefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
})
it('covers direct-apply defaults and forwards skill and tool-order config', async () => {
const oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME
const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-defaults-'))
process.env.DSH_HOME = join(home, '.dsh')
process.env.DSH_AGENTS_HOME = join(home, '.agents')
try {
const ctx = new Context()
contexts.push(ctx)
cliDemo.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
const [agent] = ctx.get('agents')?.roots() ?? []
expect(agent?.session.id).toMatch(/^main-session-/)
expect(await ctx.skills.list()).toEqual([])
} finally {
if (oldDshHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = oldDshHome
if (oldAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
else process.env.DSH_AGENTS_HOME = oldAgentsHome
}
const ctx = await mount({
provider: 'mock',
model: 'mock',
toolOrder: ['zulu', TOOL_ORDER_REST],
skills: await skillConfig(6),
workspaceContext: false,
})
ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' })
for (const name of ['alpha', 'zulu']) {
ctx.tools.register({
name,
description: name,
parameters: {},
output: { schema: { type: 'null' }, render: () => [] },
execute: async () => null,
})
}
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...')
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([
'zulu',
'alpha',
'skill',
'task_kill',
'task_list',
'task_output',
])
})
it('forwards the complete shared spine configuration', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-agents-'))
const ctx = await mount({
provider: 'mock',
model: 'mock',
maxParallelToolCalls: 3,
dshHome,
skills: { local: { agentsHome } },
toolBash: { enableRunInBackground: false },
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
workspaceContext: false,
}, true)
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
const execution: ToolExecution = {
signal: testToolSignal,
token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'],
callId: CallId('cli-demo-dsh-home'),
name: 'bash',
arguments: { command: 'true' },
}
expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: dshHome })
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
.not.toContain('run_in_background')
const id = ctx.tasks.start({
kind: 'bash',
label: 'config forwarding probe',
run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
})
const wait = vi.spyOn(ctx.tasks, 'wait')
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('cli-demo-task-config'),
name: 'task_output',
arguments: { task_id: id, wait: true },
})
expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal)
})
it('accepts false to keep task services without model-facing task controls', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
skills: { enabled: false },
toolTasks: false,
workspaceContext: false,
})
expect(ctx.get('tasks')).toBeDefined()
expect(ctx.get('tools')?.get('task_output')).toBeUndefined()
expect(ctx.get('tools')?.get('task_list')).toBeUndefined()
expect(ctx.get('tools')?.get('task_kill')).toBeUndefined()
})
it('exposes the Loader-safe namespace plugin shape and schema', () => {
expect(cliDemo.name).toBe('cli-demo')
expect(cliDemo.Config).toBeDefined()
expect('default' in cliDemo).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(cliDemo) as Record<string, unknown>
expect(unwrapped).toBe(cliDemo)
expect(unwrapped.name).toBe('cli-demo')
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -1,614 +0,0 @@
import { readdir, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage,
CallId,
LlmAdapter,
resolveRetryPolicy,
type GenerateOptions,
type ResolvedRetryPolicy,
type StreamChunk,
type TokenUsage,
} from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { afterEach, describe, expect, it } from 'vitest'
import * as cliDemo from '../src/index.ts'
import {
executeCli,
parseCliArgs,
runOneShot,
type CliResult,
} from '../src/cli.ts'
type ScriptEntry = readonly StreamChunk[] | 'hang'
class ScriptedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
private cursor = 0
private readonly retryPolicy = resolveRetryPolicy({
mode: 'normal',
backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
}, 'cli test provider retryPolicy')
constructor(private readonly script: readonly ScriptEntry[]) {
super()
}
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
return this.retryPolicy
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script[this.cursor++]
if (entry === undefined) throw new Error('script exhausted')
if (entry === 'hang') {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
await new Promise<void>((_resolve, reject) => {
if (options.signal?.aborted === true) {
reject(new Error('aborted'))
return
}
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
for (const chunk of entry) yield chunk
}
}
function textResponse(text: string, usage?: TokenUsage, finish: 'stop' | 'max-tokens' = 'stop'): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
...usage === undefined ? [] : [{ type: 'usage', usage } as const],
{ type: 'finish', reason: { kind: finish } },
]
}
function toolResponse(usage: TokenUsage): StreamChunk[] {
const id = CallId('cli-call')
const args = JSON.stringify({ text: 'round trip' })
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'working' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'working' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 1, id, name: 'echo', argumentsDelta: args },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'echo', arguments: args } },
{ type: 'usage', usage },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
function failedResponse(usage: TokenUsage): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'discarded' },
{ type: 'usage', usage },
{ type: 'finish', reason: { kind: 'error', failure: { message: 'temporary', code: 'SERVER' } } },
]
}
function reasoningResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'reasoning' },
{ type: 'reasoning-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'reasoning', text } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
interface Harness {
readonly ctx: Context
readonly agent: Agent
readonly persistenceRoot: string
}
const liveContexts: Context[] = []
async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
const root = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-'))
const ctx = new Context()
liveContexts.push(ctx)
await ctx.plugin(cliDemo, {
provider: 'mock',
model: 'mock',
persistenceRoot: root,
skills: { enabled: false },
workspaceContext: false,
})
await new Promise(resolve => setTimeout(resolve, 80))
ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script))
ctx.tools.register({
name: 'echo',
description: 'Echo text.',
parameters: { text: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value as string }],
},
execute: async args => `ECHO: ${(args as { text: string }).text}`,
})
const [agent] = ctx.agents.roots()
if (agent === undefined) throw new Error('test main agent missing')
return { ctx, agent, persistenceRoot: root }
}
async function invoke(
ctx: Context,
args: readonly string[],
options: { signal?: AbortSignal; failStdout?: boolean; failDispose?: boolean } = {},
): Promise<{ code: number; stdout: string; stderr: string }> {
let stdout = ''
let stderr = ''
const code = await executeCli(args, {
cwd: '/tmp/cli-cwd',
...options.signal === undefined ? {} : { signal: options.signal },
boot: async () => ctx,
loadEnv: () => {},
writeStdout: (chunk) => {
if (options.failStdout === true) throw new Error('stdout closed')
stdout += chunk
},
writeStderr: (chunk) => { stderr += chunk },
...options.failDispose === true
? { dispose: async (target: Context) => {
await target.fiber.dispose()
throw new Error('dispose exploded')
} }
: {},
})
return { code, stdout, stderr }
}
afterEach(async () => {
await Promise.all(liveContexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe('parseCliArgs', () => {
it('parses defaults, explicit options, spaces, and an option-like task after --', () => {
expect(parseCliArgs(['task with spaces'])).toEqual({
kind: 'run', configPath: './cordis.yml', outputFormat: 'text', task: 'task with spaces',
})
expect(parseCliArgs(['--config', 'custom.yml', '--output-format', 'stream-json', 'do it'])).toEqual({
kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it',
})
expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' })
expect(parseCliArgs(['-p', 'flag task'])).toMatchObject({ task: 'flag task' })
expect(parseCliArgs(['--prompt', 'long-flag task'])).toMatchObject({ task: 'long-flag task' })
expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' })
})
it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => {
expect(() => parseCliArgs([])).toThrow('received 0')
expect(() => parseCliArgs([' '])).toThrow('must not be blank')
expect(() => parseCliArgs(['-p', ' '])).toThrow('must not be blank')
expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2')
expect(() => parseCliArgs(['-p', 'task', 'positional'])).toThrow('mutually exclusive')
expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format')
expect(() => parseCliArgs(['-x', 'task'])).toThrow('Unknown option')
})
})
describe('runOneShot and executeCli', () => {
it('prints help and argument diagnostics without booting or contaminating stdout', async () => {
let booted = false
let stdout = ''
let stderr = ''
const runtime = {
boot: async (): Promise<Context> => { booted = true; throw new Error('unexpected') },
writeStdout: (chunk: string): void => { stdout += chunk },
writeStderr: (chunk: string): void => { stderr += chunk },
}
expect(await executeCli(['--help'], runtime)).toBe(0)
expect(stdout).toContain('Usage: dsh-cli-demo')
stdout = ''
expect(await executeCli([], runtime)).toBe(1)
expect(stdout).toBe('')
expect(stderr).toContain('received 0')
expect(booted).toBe(false)
})
it('leaves stdout empty for environment and boot failures and resolves the default config', async () => {
let bootPath = ''
let stderr = ''
const code = await executeCli(['task'], {
cwd: '/tmp/cli-work',
loadEnv: (_name, _dir, warn) => { warn('env warning\n') },
boot: async (_name, path) => { bootPath = path; throw 'boot exploded' },
writeStdout: () => { throw new Error('stdout must stay empty') },
writeStderr: (chunk) => { stderr += chunk },
})
expect(code).toBe(1)
expect(bootPath).toBe(resolve('/tmp/cli-work/cordis.yml'))
expect(stderr).toContain('env warning')
expect(stderr).toContain('boot exploded')
})
it('contains a thrown value whose inspection and coercion both fail', async () => {
const hostile = new Proxy({}, {
getPrototypeOf: () => { throw new Error('prototype trap escaped') },
get: (target, key, receiver) => {
if (key === Symbol.toPrimitive) throw new Error('coercion escaped')
return Reflect.get(target, key, receiver) as unknown
},
})
let stdout = ''
let stderr = ''
const code = await executeCli(['task'], {
boot: async () => { throw hostile },
loadEnv: () => {},
writeStdout: (chunk) => { stdout += chunk },
writeStderr: (chunk) => { stderr += chunk },
})
expect(code).toBe(1)
expect(stdout).toBe('')
expect(stderr).toBe('dsh-cli-demo: [unrenderable thrown value]\n')
})
it('interrupts Loader boot and contains every late boot outcome', async () => {
const abort = new AbortController()
const lateContext = new Context()
liveContexts.push(lateContext)
const boot = Promise.withResolvers<Context>()
const disposed = Promise.withResolvers<undefined>()
let disposeCalls = 0
let stderr = ''
const running = executeCli(['task'], {
signal: abort.signal,
boot: () => boot.promise,
loadEnv: () => {},
writeStdout: () => {},
writeStderr: (chunk) => { stderr += chunk },
dispose: async (ctx) => {
disposeCalls += 1
await ctx.fiber.dispose()
disposed.resolve(undefined)
},
})
abort.abort('received SIGTERM')
await expect(running).resolves.toBe(1)
expect(stderr).toContain('received SIGTERM')
expect(disposeCalls).toBe(0)
boot.resolve(lateContext)
await disposed.promise
expect(disposeCalls).toBe(1)
const rejectedBoot = Promise.withResolvers<Context>()
const rejectedAbort = new AbortController()
const rejected = executeCli(['task'], {
signal: rejectedAbort.signal,
boot: () => rejectedBoot.promise,
loadEnv: () => {},
writeStdout: () => {},
writeStderr: () => {},
})
rejectedAbort.abort('stop rejected boot')
await expect(rejected).resolves.toBe(1)
rejectedBoot.reject(new Error('late boot rejection'))
await Promise.resolve()
let ordinaryBootStderr = ''
const ordinaryBootFailure = await executeCli(['task'], {
signal: new AbortController().signal,
boot: async () => { throw new Error('ordinary boot failure') },
loadEnv: () => {},
writeStdout: () => {},
writeStderr: (chunk) => { ordinaryBootStderr += chunk },
})
expect(ordinaryBootFailure).toBe(1)
expect(ordinaryBootStderr).toContain('ordinary boot failure')
const failedCleanupBoot = Promise.withResolvers<Context>()
const failedCleanupAbort = new AbortController()
const cleanupFailure = Promise.withResolvers<undefined>()
const failedCleanupContext = new Context()
liveContexts.push(failedCleanupContext)
const failedCleanup = executeCli(['task'], {
signal: failedCleanupAbort.signal,
boot: () => failedCleanupBoot.promise,
loadEnv: () => {},
writeStdout: () => {},
writeStderr: (chunk) => {
if (chunk.includes('dispose after interrupted boot failed: late cleanup')) cleanupFailure.resolve(undefined)
},
dispose: async (ctx) => {
await ctx.fiber.dispose()
throw new Error('late cleanup')
},
})
failedCleanupAbort.abort('stop failed cleanup boot')
await expect(failedCleanup).resolves.toBe(1)
failedCleanupBoot.resolve(failedCleanupContext)
await cleanupFailure.promise
})
it('renders text, flushes a persisted fresh session, and disposes the context', async () => {
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
const output = await invoke(ctx, ['task'])
expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
expect(agent.status).toBe('idle')
const files = await readdir(persistenceRoot, { recursive: true })
expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
})
it('writes correlated session events in stream-json mode', async () => {
const { ctx } = await harness([textResponse('streamed answer')])
const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
const records = output.stdout.trim().split('\n').map(line => JSON.parse(line) as { type: string })
expect(output.code).toBe(0)
expect(records.some(record => record.type === 'session_event')).toBe(true)
expect(records.at(-1)).toMatchObject({ type: 'result', output: 'streamed answer' })
})
it('sums usage across tool steps and selects the last text-bearing assistant message', async () => {
const first = { inputTokens: 10, outputTokens: 3, cacheReadTokens: 2, cacheWriteTokens: 1 }
const second = { inputTokens: 7, outputTokens: 5, cacheReadTokens: 4, reasoningTokens: 6 }
const { ctx } = await harness([toolResponse(first), textResponse('done', second)])
const output = await invoke(ctx, ['--output-format', 'json', 'task'])
const result = JSON.parse(output.stdout) as CliResult
expect(output.code).toBe(0)
expect(result).toMatchObject({ type: 'result', output: 'done' })
expect(result.usage).toEqual({
inputTokens: 17,
outputTokens: 8,
cacheReadTokens: 6,
cacheWriteTokens: 1,
reasoningTokens: 6,
})
})
it('reports usage committed by the recovered assistant message', async () => {
const failed = { inputTokens: 11, outputTokens: 2, cacheReadTokens: 3 }
const recovered = { inputTokens: 7, outputTokens: 5, reasoningTokens: 4 }
const { ctx } = await harness([failedResponse(failed), textResponse('done', recovered)])
const result = await runOneShot(ctx, { task: 'task' })
expect(result.usage).toEqual({
inputTokens: 7,
outputTokens: 5,
reasoningTokens: 4,
})
})
it('keeps the prior text when a later assistant message has no text blocks', async () => {
const { ctx } = await harness([
toolResponse({ inputTokens: 1, outputTokens: 1 }),
reasoningResponse('reasoning only'),
])
const result = await runOneShot(ctx, { task: 'task' })
expect(result.output).toBe('working')
})
it('observes only the correlated main message turn', async () => {
const { ctx, agent } = await harness([
textResponse('startup'),
textResponse('autonomous'),
textResponse('streamed'),
])
const other = ctx.sessions.create(SessionId('unrelated'))
let startupStarted!: () => void
const started = new Promise<void>((resolve) => { startupStarted = resolve })
const releaseStartup = Promise.withResolvers<undefined>()
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message'
&& event.data.turn === 1) startupStarted()
})
ctx.on('agent/turn-stopping', async ({ agent: subject, turn }) => {
if (subject === agent && turn === 1) await releaseStartup.promise
})
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'startup' }],
source: { kind: 'plugin', plugin: 'startup' },
}))
await started
const followup = agent.followup.bind(agent)
let injectedBeforeReceipt = false
agent.followup = (input) => {
if (!injectedBeforeReceipt && input.source.kind === 'user') {
injectedBeforeReceipt = true
agent.inbox.append('next-step', createUserMessage({
content: [{ type: 'text', text: 'wrong receipt' }],
source: { kind: 'plugin', plugin: 'test-wrong-receipt' },
}))
other.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'unrelated session event' }],
source: { kind: 'plugin', plugin: 'test' },
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'uncorrelated main-session event' }],
source: { kind: 'plugin', plugin: 'test-before-receipt' },
}), { surfaceOp: 'append' })
}
followup(input)
}
let replacementQueued = false
ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || replacementQueued) return
replacementQueued = true
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'autonomous' }],
source: { kind: 'plugin', plugin: 'test' },
}))
other.append('turn/start', { turn: 1 })
other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
const streamed: { sessionId: string; event: SessionEvent }[] = []
const result = runOneShot(ctx, {
task: 'task',
onEvent: (sessionId, event) => { streamed.push({ sessionId, event }) },
})
releaseStartup.resolve(undefined)
const outcome = await result
expect(outcome).toMatchObject({ type: 'result', output: 'streamed' })
const events = streamed.map(item => item.event)
expect(events.find(event => event.type === 'turn/start'))
.toMatchObject({ type: 'turn/start', data: { turn: 3 } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 3 } })
expect(streamed.every(item => item.sessionId === agent.session.id)).toBe(true)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'test')).toBe(false)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'test-before-receipt')).toBe(false)
})
it('correlates a task whose step history is replaced', async () => {
const { ctx } = await harness([textResponse('rewritten answer')])
ctx.on('agent/pre-step', async () => ({
kind: 'enter',
messages: [createUserMessage({
content: [{ type: 'text', text: 'rewritten task' }],
source: { kind: 'plugin', plugin: 'test' },
})],
}))
await expect(runOneShot(ctx, { task: 'original task' })).resolves.toMatchObject({
type: 'result',
output: 'rewritten answer',
})
})
it('settles rejected tasks at whole-agent idle without attributing a result', async () => {
const blocked = await harness([])
blocked.ctx.on('agent/pre-step', async () => ({
kind: 'reject' as const,
}))
await expect(runOneShot(blocked.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' })
const failed = await harness([])
failed.ctx.on('agent/pre-step', async () => { throw new Error('pre-step exploded') })
await expect(runOneShot(failed.ctx, { task: 'task' })).resolves.toMatchObject({ output: '' })
})
it('emits partial data without attributing a turn outcome', async () => {
const { ctx } = await harness([textResponse('partial', { inputTokens: 2, outputTokens: 3 }, 'max-tokens')])
const output = await invoke(ctx, ['--output-format', 'json', 'task'])
expect(JSON.parse(output.stdout)).toMatchObject({ type: 'result', output: 'partial' })
expect(output.code).toBe(0)
expect(output.stderr).toBe('')
})
it('cancels an active turn, emits its durable aborted result, and disposes', async () => {
const { ctx, agent } = await harness(['hang'])
const abort = new AbortController()
let started!: () => void
const running = new Promise<void>((resolveStarted) => { started = resolveStarted })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/chunk') started()
})
const outcome = invoke(ctx, ['--output-format', 'json', 'task'], { signal: abort.signal })
await running
abort.abort('received SIGINT')
const output = await outcome
expect(output.stdout).toBe('')
expect(output.code).toBe(1)
expect(output.stderr).toContain('received SIGINT')
expect(agent.status).toBe('idle')
})
it('contains stream-writer failures, cancels, flushes, and returns the output error', async () => {
const { ctx, agent } = await harness(['hang'])
await expect(runOneShot(ctx, {
task: 'task',
onEvent: () => { throw new Error('stream sink failed') },
})).rejects.toThrow('stream sink failed')
expect(agent.status).toBe('idle')
})
it('handles cancellation before submission, a missing main agent, and final-output failure', async () => {
const early = await harness([textResponse('unused')])
const fakeSignal = {
aborted: true,
reason: undefined,
} as unknown as AbortSignal
await expect(runOneShot(early.ctx, { task: 'task', signal: fakeSignal })).rejects.toThrow('interrupted')
const raced = await harness([textResponse('unused')])
let registrations = 0
const racedSignal = {
aborted: false,
reason: 'cancel before followup',
addEventListener: (_type: string, listener: () => void) => {
registrations += 1
if (registrations === 2) listener()
},
removeEventListener: () => {},
} as unknown as AbortSignal
await expect(runOneShot(raced.ctx, { task: 'task', signal: racedSignal }))
.rejects.toThrow('cancel before followup')
expect(raced.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
const preBootAbort = new AbortController()
preBootAbort.abort('before boot completed')
const preBoot = await invoke(early.ctx, ['task'], { signal: preBootAbort.signal })
expect(preBoot).toMatchObject({ code: 1, stdout: '' })
expect(preBoot.stderr).toContain('before boot completed')
const empty = new Context()
liveContexts.push(empty)
await expect(runOneShot(empty, { task: 'task' })).rejects.toThrow('exactly one top-level agent')
const final = await harness([textResponse('answer')])
const output = await invoke(final.ctx, ['task'], { failStdout: true })
expect(output.code).toBe(1)
expect(output.stdout).toBe('')
expect(output.stderr).toContain('stdout closed')
expect(final.agent.status).toBe('idle')
const disposal = await harness([textResponse('answer')])
const disposalOutput = await invoke(disposal.ctx, ['task'], { failDispose: true })
expect(disposalOutput).toMatchObject({ code: 1, stdout: 'answer\n' })
expect(disposalOutput.stderr).toContain('dispose exploded')
})
it('reports disposal failure alongside an earlier run failure', async () => {
const ctx = new Context()
liveContexts.push(ctx)
const output = await invoke(ctx, ['task'], { failDispose: true })
expect(output).toEqual({
code: 1,
stdout: '',
stderr: 'dsh-cli-demo: config must create exactly one top-level agent, found 0\n'
+ 'dsh-cli-demo: dispose failed: dispose exploded\n',
})
})
it('cancels startup work and queued work before the correlated turn begins', async () => {
const startup = await harness(['hang'])
let started!: () => void
const running = new Promise<void>((resolveStarted) => { started = resolveStarted })
startup.ctx.on('session/event', (session, event) => {
if (session === startup.agent.session && event.type === 'assistant/chunk') started()
})
startup.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }))
await running
const startupAbort = new AbortController()
const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal })
startupAbort.abort('cancel startup')
await expect(waiting).rejects.toThrow('cancel startup')
await startup.agent.whenIdle()
const queued = await harness([textResponse('unused')])
const queuedAbort = new AbortController()
queued.ctx.on('session/event', (session, event) => {
if (session === queued.agent.session && event.type === 'agent/inbox/spliced'
&& event.data.inserted.some(message => message.source.kind === 'user')) {
queueMicrotask(() => { queuedAbort.abort('cancel queued') })
}
})
await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued')
await queued.agent.whenIdle()
})
})

View File

@@ -1,47 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src/**/*.ts"],
"references": [
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../agent-spine-demo"
},
{
"path": "../../session-persistence/session-checkpoint-policy"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../ui/app-boot"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,13 +0,0 @@
import { defineConfig } from 'tsdown'
/** Builds the plugin and executable entries from declarations emitted by `tsc -b`. */
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})

View File

@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { decodeGoalChange } from '@deepseek-ai/dsh-goal'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
const binScript = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/headless-driver.ts', import.meta.url))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/goal-domain/cordis.yml',
import.meta.url,
@@ -30,8 +30,9 @@ describe('goal domain through a real cordis.yml and headless process', () => {
label: 'goal-domain',
tempDirPrefix: 'goal-domain-e2e-',
binScript,
libBinScript: binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'json', 'prove the persisted goal domain'],
binArgs: [configPath, 'prove the persisted goal domain'],
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
const logs = await jsonlFiles(join(cwd, '.sessions'))
@@ -41,7 +42,7 @@ describe('goal domain through a real cordis.yml and headless process', () => {
},
})
expect(stderr).toBe('')
const result = JSON.parse(stdout) as Record<string, unknown>
const result = JSON.parse(stdout.trimEnd().split('\n').at(-1) ?? '') as Record<string, unknown>
expect(result).toMatchObject({
type: 'result',
})

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/host/apiproxy/README.md
README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f
README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9
README.md: a3c9f214690144ec0f39a8690e4fd346f5e315e2
README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340

View File

@@ -52,7 +52,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh run` headless.
## Model Experience

View File

@@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
## 载体层(`/client` + 根路径)
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh run` headless 模式使用。
## 模型体验

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/sandbox/sandbox-local/README.md
README.md: f6a1cc2b3e454e0670a564151d41182ec515bdcf
README.zh.md: 18b66af350932fc8d5c4f184d0e7fa049f910250
README.md: 23d3a32451c105c71c0a7399ed051288b70753f3
README.zh.md: 165a6fc88a9fdd219c3ddb016cdf415504556d8c

View File

@@ -12,7 +12,7 @@ Policy is per call; the provider stores only the mechanism and cached runner ver
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift.
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift.
```yaml
- id: sandbox

View File

@@ -12,7 +12,7 @@
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list因此恰好约束相应模式承诺的文件操作`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`。Apple 将 `sandbox-exec` CLI命令行界面标为 deprecated但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。
[`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止契约漂移。
```yaml
- id: sandbox

View File

@@ -31,7 +31,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"node-addon-landlock-run": "0.0.0-test.0",
"@deepseek-ai/node-addon-landlock-run": "workspace:*",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -12,7 +12,7 @@ import {
LAUNCHER_FAILURE_EXIT,
launcherPath as landlockLauncherPath,
probe as defaultProbeLandlock,
} from 'node-addon-landlock-run'
} from '@deepseek-ai/node-addon-landlock-run'
import { Context } from 'cordis'
import z from 'schemastery'
import { assertNever } from '@deepseek-ai/dsh-llm'

View File

@@ -4,7 +4,7 @@
* @module @deepseek-ai/dsh-sandbox-local/profiles
*/
import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run'
import { grantArgs as landlockGrantArgs } from '@deepseek-ai/node-addon-landlock-run'
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'

View File

@@ -6,11 +6,11 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { launcherPath } from 'node-addon-landlock-run'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
/**
* Keyless backend integration through `confine()` and the registry `landlock-run` launcher, with
* Keyless backend integration through `confine()` and the workspace `landlock-run` launcher, with
* bwrap forced off. Tests assert real world effects; consumer coverage lives in dsh-bash-sandbox.
* Skips when the platform package or enforcing kernel is unavailable. HOME-based workspaces avoid
* Landlock's wholesale `/tmp` grant, so workspace-write proves the workspace-root grant itself.

View File

@@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { LAUNCHER_FAILURE_EXIT } from 'node-addon-landlock-run'
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import {

View File

@@ -7,20 +7,25 @@ import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
/**
* Keyless publish-path rehearsal. It packs the package and workspace peers, installs those exact
* tarballs in an external plain-Node consumer, and lets npm resolve the registry Landlock launcher
* plus its platform package. No tsx, path mapping, or workspace resolution can hide missing files,
* dependency errors, or lost executable modes.
* Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current
* repository's Landlock entry/platform packages, then installs those exact tarballs in an external
* plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy,
* tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost
* executable modes. npm may still query registry metadata for an incompatible optional platform
* package that cannot supply the host launcher.
*
* The installed launcher must match the host architecture, remain executable, and either confine a
* real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or
* before `pnpm run build`; launcher byte provenance belongs to its upstream release pipeline.
* before the harness and native packages are built.
*/
const packageDir = fileURLToPath(new URL('..', import.meta.url))
const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url))
const nativeDir = join(repoRoot, 'native/landlock-run')
const sourceLauncher = join(nativeDir, 'packages', `linux-${process.arch}`, 'bin', 'landlock-run')
const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${process.arch}`
/** The closure the consumer needs: the package and its transitive `@deepseek-ai` peers; the launcher family arrives from the registry. */
/** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */
const WORKSPACE_CLOSURE = [
'packages/sandbox/sandbox-local',
'packages/sandbox/sandbox',
@@ -36,6 +41,8 @@ const E_MACHINE = { x64: 62, arm64: 183 }[process.arch as 'x64' | 'arm64']
const packable = process.platform === 'linux'
&& E_MACHINE !== undefined
&& existsSync(join(packageDir, 'lib', 'index.js'))
&& existsSync(join(nativeDir, 'packages/entry/lib/index.js'))
&& existsSync(sourceLauncher)
let consumerDir = ''
let workDir = ''
@@ -57,7 +64,20 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
consumerDir = mkdtempSync(join(tmpdir(), 'dsh-packed-consumer-'))
workDir = mkdtempSync(join(tmpdir(), 'dsh-packed-work-'))
// Pack each closure member with the exact bytes publish would upload.
const nativePackDest = join(packDest, 'native')
const nativePack = spawnSync('node', ['./scripts/pack-release.mjs', nativePackDest, '--current-platform-only'], {
cwd: nativeDir,
encoding: 'utf8',
timeout: 120_000,
})
expect(nativePack.status, `native pack failed:\n${nativePack.stdout}\n${nativePack.stderr}`).toBe(0)
const nativeTarballs = readFileSync(join(nativePackDest, 'publish-order.txt'), 'utf8')
.trim()
.split('\n')
.map(tarball => join(nativePackDest, tarball))
// Pack each harness closure member with the exact bytes publish would upload.
const tarballs: string[] = []
for (const pkg of WORKSPACE_CLOSURE) {
const pack = spawnSync('pnpm', ['pack', '--pack-destination', packDest], {
@@ -69,6 +89,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
const lines = pack.stdout.trim().split('\n')
tarballs.push(lines[lines.length - 1] as string)
}
tarballs.push(...nativeTarballs)
// Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional
// dependencies because the launcher selects its OS/CPU package through one.
@@ -88,7 +109,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { Context } from 'cordis'
import { launcherPath } from 'node-addon-landlock-run'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
const ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
@@ -124,18 +145,19 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
await Promise.all([consumerDir, workDir].filter(Boolean).map(dir => rm(dir, { recursive: true, force: true })))
})
it('installs the registry launcher for this host: present, EXECUTABLE, right ELF arch', () => {
const installed = join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run')
it('installs this checkout\'s launcher for the host: present, executable, byte-identical, and right ELF arch', () => {
const installed = join(consumerDir, 'node_modules', ...platformPackageName.split('/'), 'bin', 'landlock-run')
expect(existsSync(installed), 'platform package missing from the installed tree').toBe(true)
// A tarball or extraction step that strips the mode bit would leave the
// probe failing exactly like a non-enforcing kernel — assert it apart.
expect(() => { accessSync(installed, constants.X_OK) }, 'installed launcher is not executable').not.toThrow()
expect(readFileSync(installed), 'installed launcher bytes').toEqual(readFileSync(sourceLauncher))
expect(readFileSync(installed).readUInt16LE(18), 'ELF e_machine').toBe(E_MACHINE)
})
it('the installed provider resolves the launcher INSIDE the consumer node_modules platform package', () => {
expect(verdict.launcher)
.toBe(join(consumerDir, 'node_modules', `node-addon-landlock-run-linux-${process.arch}`, 'bin', 'landlock-run'))
.toBe(join(consumerDir, 'node_modules', ...platformPackageName.split('/'), 'bin', 'landlock-run'))
})
it('confines through the installed launcher (enforcing kernel) or fails closed (non-enforcing) — never unconfined', async () => {

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../native/landlock-run/packages/entry"
},
{
"path": "../../llm/llm"
},

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/skill/README.md
README.md: d10049ac3e741350fddb42f430b70f063da1d12d
README.zh.md: 67f2da6f75edecd180ff861122c6392684ed9bdb
README.md: 533904859ad998de4f371a073fde98b68660097b
README.zh.md: 1fad581cc61a05251f671577dcb7edab37281283

View File

@@ -7,6 +7,7 @@ This family discovers reusable agent instructions and exposes them to the model
| Package | Role | ctx key |
|---|---|---|
| [`skill/`](skill/README.md) | Defines skill provider registration and lookup | `ctx.skills` |
| [`skill-badge/`](skill-badge/README.md) | Contributes the optional bundled dsh badge skill | registers on `ctx.skills` |
| [`skill-local/`](skill-local/README.md) | Discovers skills from local filesystems | registers on `ctx.skills` |
| [`tool-skill/`](tool-skill/README.md) | Publishes the skill catalog and model-facing loader | registers on `ctx.tools` |

View File

@@ -7,6 +7,7 @@
| 包 | 职责 | ctx 键 |
|---|---|---|
| [`skill/`](skill/README.md) | 定义 skill 提供方注册和查找 | `ctx.skills` |
| [`skill-badge/`](skill-badge/README.md) | 贡献可选的内置 dsh 徽章 skill | 注册到 `ctx.skills` |
| [`skill-local/`](skill-local/README.md) | 从本地文件系统发现 skill | 注册到 `ctx.skills` |
| [`tool-skill/`](tool-skill/README.md) | 发布 skill 目录和面向模型的 loader | 注册到 `ctx.tools` |

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 packages/examples/cli-demo/README.md
README.md: 6e46ae81421c23806524b0784a976e9f3c8eeab8
README.zh.md: b032023fee4bf9d992217cc51731f6356f875daf
# pnpm run verify-translation-pairing --write packages/skill/skill-badge/README.md
README.md: 49b38023a7c110bb52bb351668905c702e251216
README.zh.md: bf7eb0d7d4c0552c07f9cdf5665f8a20df829483

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-skill-badge
English | [中文](README.zh.md)
Optional bundled skill provider that contributes `dsh-badge` to `ctx.skills`. The skill supplies the official “powered by dsh” Markdown snippets and the packaged PNG for systems that cannot import a remote image reliably.
Mount the plugin to enable the provider. It has no configuration. The shipped CLI composition includes the plugin as `disabled: true`; users must explicitly enable its `skill-badge` row before the skill enters a catalog.
The provider exposes its packaged `assets/` directory as the skill resource base. `dsh-badge.png` is the 726×120 source asset, and consumers render it at 121×20.
## Model Experience
Indirectly, through `@deepseek-ai/dsh-tool-skill`, which renders the catalog entry and selected skill body.
#### KV Cache effect
Disabled by default, the plugin changes no request. When enabled, its catalog entry and any loaded body change the provider KV prefix at their insertion points.
## Known Limitations and Deferred Work
- The provider contributes one fixed skill and has no runtime customization.
- Remote Markdown uses Shields.io; use the packaged PNG when the target cannot fetch remote images reliably.

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-skill-badge
[English](README.md) | 中文
可选的内置 skill技能提供方`ctx.skills` 贡献 `dsh-badge`。该 skill 提供官方「powered by dsh」Markdown 片段和随包分发的 PNG供无法可靠导入远程图片的系统使用。
挂载该插件即可启用提供方。它没有配置。交付的 CLI命令行界面组合以 `disabled: true` 包含该插件;用户必须显式启用其 `skill-badge` 配置行,该 skill 才会进入目录。
该提供方将随包分发的 `assets/` 目录作为 skill 资源基底公开。`dsh-badge.png` 是尺寸为 726×120 的源图资源,消费方以 121×20 的尺寸渲染。
## 模型体验
通过 `@deepseek-ai/dsh-tool-skill` 间接影响模型;该包会渲染目录条目和所选 skill 的正文。
#### KV Cache 影响
该插件默认禁用,不会改变任何请求。启用后,其目录条目和任何已加载正文都会在各自插入点改变提供方的 KV 前缀。
## 已知限制与暂缓事项
- 该提供方只贡献一个固定 skill不提供运行时自定义。
- 远程 Markdown 使用 Shields.io当目标环境无法可靠获取远程图片时请使用随包分发的 PNG。

View File

@@ -0,0 +1,31 @@
# dsh Badge
Add the official “powered by dsh” badge without recreating or restyling it.
## Assets
- Local PNG: [`dsh-badge.png`](dsh-badge.png), 726×120 source image; render at 121×20
- Shields.io image URL: `https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white`
- Project URL: `https://github.com/deepseek-ai/deepseek-harness-sdk`
## Markdown
Use this linked badge in Markdown:
```markdown
[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk)
```
If attribution should not be linked, use:
```markdown
![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)
```
## Usage rules
- For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
- For Feishu and other systems that import remote images unreliably, upload `dsh-badge.png` from this skill directory instead of generating another badge.
- Preserve the badge's 121×20 dimensions and aspect ratio.
- Place the badge at the end of the attributed document or section unless the user specifies another position.
- Do not substitute another color, logo, label, or project URL.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-skill-badge",
"description": "Bundled dsh badge skill provider for DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"assets",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,60 @@
/**
* Bundled `dsh-badge` skill provider.
*
* @module @deepseek-ai/dsh-skill-badge
*/
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import {
BUNDLED_SKILL_RANK,
type SkillCandidate,
type SkillDefinition,
type SkillProvider,
} from '@deepseek-ai/dsh-skill'
const PROVIDER_NAME = 'dsh-badge'
const SKILL_BODY_URL = new URL('../assets/dsh-badge.md', import.meta.url)
const RESOURCE_BASE = {
kind: 'directory',
path: fileURLToPath(new URL('../assets/', import.meta.url)),
} as const
const INVOCATION = { modelInvocable: true, userInvocable: true } as const
const DESCRIPTION = 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.'
const CANDIDATE: SkillCandidate = {
name: 'dsh-badge',
description: DESCRIPTION,
invocation: INVOCATION,
provider: PROVIDER_NAME,
source: 'bundled',
resourceBase: RESOURCE_BASE,
rank: BUNDLED_SKILL_RANK,
locator: SKILL_BODY_URL,
}
const provider: SkillProvider = {
name: PROVIDER_NAME,
list: () => Promise.resolve([CANDIDATE]),
async get(_candidate): Promise<SkillDefinition> {
return {
name: CANDIDATE.name,
description: CANDIDATE.description,
invocation: CANDIDATE.invocation,
provider: CANDIDATE.provider,
source: CANDIDATE.source,
resourceBase: RESOURCE_BASE,
content: await readFile(SKILL_BODY_URL, 'utf8'),
}
},
}
/** Cordis plugin name. */
export const name = 'skill-badge'
/** Service required by the bundled provider. */
export const inject = ['skills']
/** Register the bundled `dsh-badge` provider on `ctx.skills`. */
export function apply(ctx: Context): void {
ctx.skills.registerProvider(() => provider)
}

View File

@@ -1,22 +1,22 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-cli-demo`.
* @module @deepseek-ai/dsh-cli-demo/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-skill-badge`.
* @module @deepseek-ai/dsh-skill-badge/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-cli-demo'
const PACKAGE_NAME = '@deepseek-ai/dsh-skill-badge'
/** Cordis companion plugin name. */
export const name = 'cli-demo-invariant'
export const name = 'skill-badge-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this composition package owns no independent event stream or mutable data;
* Loader and built-entry tests cover its wiring.
* No runtime invariant: the package owns one immutable provider registration,
* while the skill registry owns registration uniqueness and lifecycle checks.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,40 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillBadge from '@deepseek-ai/dsh-skill-badge'
describe('dsh-skill-badge', () => {
it('registers and disposes the bundled badge skill', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const fiber = await ctx.plugin(SkillBadge)
const resourcePath = fileURLToPath(new URL('../assets/', import.meta.url))
expect(await ctx.skills.list()).toEqual([{
name: 'dsh-badge',
description: 'Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.',
invocation: { modelInvocable: true, userInvocable: true },
provider: 'dsh-badge',
source: 'bundled',
resourceBase: { kind: 'directory', path: resourcePath },
}])
const loaded = await ctx.skills.get('dsh-badge')
expect(loaded?.content).toContain('Preserve the badge\'s 121×20 dimensions')
expect(loaded?.resourceBase).toEqual({ kind: 'directory', path: resourcePath })
await fiber.dispose()
expect(await ctx.skills.list()).toEqual([])
})
it('ships the official 726×120 PNG unchanged', async () => {
const image = await readFile(new URL('../assets/dsh-badge.png', import.meta.url))
expect(image.readUInt32BE(16)).toBe(726)
expect(image.readUInt32BE(20)).toBe(120)
expect(createHash('sha256').update(image).digest('hex')).toBe(
'f2c4f5ec9cbe847c0c763545c4d839efa8485bc74203733d0a0e8259f233c653',
)
})
})

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../skill" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -21,6 +21,7 @@ import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import {
BUNDLED_SKILL_RANK,
isSkillName,
type SkillCandidate,
type SkillDefinition,
@@ -40,7 +41,6 @@ const USER_AGENTS_RANK = 500
const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200
const DEFAULT_WATCH_POLL_INTERVAL_MS = 100
const DEFAULT_WATCH_MAX_PROJECTS = 128
const BUNDLED_RANK = 600
export const name = 'skill-local'
export const inject = ['skills']
@@ -256,7 +256,7 @@ export class LocalSkillProvider implements SkillProvider {
)
}
if (this.bundledSkillDir !== undefined) {
roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true })
roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_SKILL_RANK, trustedHost: true })
}
return roots
}

View File

@@ -20,6 +20,9 @@ const MAX_COLLECT_ATTEMPTS = 2
const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
/** Standard precedence rank for packaged skill providers and local bundled roots. */
export const BUNDLED_SKILL_RANK = 600
/**
* Return whether a string is a valid kebab-case skill name.
* @param name - candidate skill name to validate.

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/support/llm-replay/README.md
README.md: 46d391970f320708914d11f0868cbbc5361ae196
README.zh.md: a67b078a1396968dc3ddecb0e616a832c4faaf3a
README.md: a6c087778b8e64124590fb6be7a652b58e1b6343
README.zh.md: 241edb9c9b2400519155ccd7161eb7f34b9f5bbe

Some files were not shown because too many files have changed in this diff Show More