Merge pull request #3849 from deepseek-harness/turtle/issue-3848-headless-machine-readable-run

feat(headless): 支持 stdin 任务、--session-id 与 --json 事件流
This commit is contained in:
lsdsjy authored and GitHub committed 2026-09-11 20:03:00 +08:00
commit 315e30e9f2
34 files changed
+2334 -194

No files matched your search

@@ -0,0 +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 .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md
2026-09-09-headless-machine-readable-run-surface.md: 317f213095f86e94104c013139390cdf988f95fe
2026-09-09-headless-machine-readable-run-surface.zh.md: 0114f87aef4f25db0887663464973bb2f762b2d5
@@ -0,0 +1,100 @@
# Agent Note: Headless machine-readable run surface
Status: implemented
English | [中文](2026-09-09-headless-machine-readable-run-surface.zh.md)
## Problem
`dsh --profile headless` serves a human terminal: the task arrives only through argv, stdout carries one final assistant message, provider reasoning streams to stderr, and every run creates a fresh random session. [Headless is a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns that transport and completion contract; [headless reasoning progress](../../archived/feature/2026-08-21-headless-reasoning-progress.md) owns the stderr projection.
A supervising process that drives one headless process per wake, such as an external agent runtime, needs three things that contract does not provide. It needs the task over a private pipe rather than argv, because a long prompt exceeds the argument limit and argv is visible to other processes. It needs a machine-readable stream that separates assistant text, reasoning, tool calls and results, turn boundaries, and usage, because scraping stderr yields only reasoning and the final stdout line yields no tool activity. It needs an exact session identity it can pass back on the next wake, because a fresh random session per process makes continuity impossible.
## Decision
The `dsh-headless` bundle owns an opt-in machine-readable run surface. The default invocation keeps the previous contract unchanged: one final assistant message on stdout, reasoning on stderr, exit 0 exactly when the terminal `turn/end` reason is `completed`.
Three additions extend the app-owned command line that [Apps own their command lines](../../archived/architecture/2026-08-06-app-owned-command-line.md) established:
- `--json` replaces the stdout payload with newline-delimited JSON run events. Reasoning becomes an event instead of stderr output, so stderr carries only `dsh:` diagnostics.
- `--session-id <id>` selects the exact session identity: adopt the persisted session with that id, and fail when no such log exists. Without the flag the run mints `session-<uuid>` as before.
- The task text also arrives on stdin when no positional task is present, or when the positional is `-`.
A per-run `--model` override is deliberately out of scope; the composition default stays authoritative.
The product change is confined to `packages/bundle/headless`: `src/startup.ts`, `src/index.ts`, the new `src/json-stream.ts`, the package manifest and `tsconfig.json`, and its tests. Around it, the product-profile expectation test in `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` covers both output modes end to end and adds one optional caller-owned `cwd` to the `packages/test-support/loader-smoke` harness so two wakes can share a world. `scripts/check-workspace-constraints.ts` and the package manifest publish the shared `lib/json-stream-*.js` chunk both entries import, and `pnpm-lock.yaml` records the new `@deepseek-ai/dsh-session-query` workspace link. No core session, persistence, session-controller, base composition, or launcher file changes.
### Command-line contract
```text
dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
```
Task resolution order: joined positionals, then `-`, then piped stdin. A whitespace-only positional is a usage error on its own, even when stdin is not a terminal, so an accidental blank argument never consumes a pipe; a task that is absent entirely is a usage error only when stdin is a terminal. A lone `-` is the only stdin marker: mixing it with other task words is a usage error instead of a task that starts with a dash. A piped task is sent verbatim, trailing newline included. In `--json` mode every usage error — including commander's own grammar rejections such as an unknown option or a missing option value — writes the `error` event before the process exits, because the runner never mounts to write it; the event message omits commander's `error: ` prefix so one event type carries one message shape.
`--json` changes the stdout payload and the destination of the reasoning projection only. Exit status, shutdown ordering, session flush, and the durable session log are unchanged, so a supervisor classifies a run exactly as it does today.
### Event stream
`--json` writes one JSON object per line to stdout and nothing else. The vocabulary is a projection of the session event log, not the log itself.
| `type` | Fields | Emitted |
|---|---|---|
| `session` | `sessionId`, `cwd` | first line, before any model output |
| `status` | `phase` (`turn_start`, `step_start`, `step_end`, `turn_end`), `turn`, `step`, `usage`, `reason` | one per boundary |
| `text` | `text` | one committed assistant text block |
| `thinking` | `text` | one committed reasoning block |
| `tool_call` | `callId`, `tool`, `input` | once per call |
| `tool_result` | `callId`, `status`, `result` | once per appended result |
| `error` | `message` | a failure the runner raises outside a turn |
| `final` | `text` | last line |
Projection rules:
- Text and reasoning are projected only from a committed `assistant/message`, never from live attempt deltas. A retried or discarded attempt appends `assistant/attempt`, which the projection ignores, so the stream never carries content the durable log does not contain ([publish state only at its commit point](../../../../packages/AGENTS.md)).
- Each committed content block becomes exactly one `text` or `thinking` event in content order; `tool-call` blocks are not projected because the `tool/call` event owns them. `user/message` echoes and internal session events (title, model selection, projection, checkpoint, goal, subagent) are not projected.
- A `tool/result` is projected only when its `surfaceOp` is `append`. A compaction replacement of an older result is history, and projecting it would emit a call id with no matching `tool_call`.
- Every projected string and object key is bounded at 8 KiB, an event with a cut value carries `truncated: true`, and one serialized event line, newline included, is bounded at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`, while a payload nested 64 levels or deeper is cut at that depth so no legal input can overflow the bounding recursion. This includes the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter, an empty tool-argument string projects as `{}` to match the executor, and arguments that JSON cannot round-trip (an overflowing number such as `1e400`) keep their raw text rather than the `null` that `JSON.stringify` would report. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
- Text and reasoning arrive when the step commits, not per token; default-mode stderr reasoning remains the only live text channel. A turn that fails in-turn still ends the stream with `final` and no `error` event, so a supervisor classifies that run from the exit code and the `turn_end` reason even when the stream is well formed.
- `usage` appears on `step_end` only when every attempt in the step reported a sample, summed across them — including a retried attempt whose only usage sample sits in its discarded `assistant/attempt` stream — so a partial total is never presented as exact.
- Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary.
### Session identity
The runtime owns identity. A run without `--session-id` mints `session-<uuid>` and reports it in the first event. A supervisor persists that value and passes it back on the next wake.
`--session-id <id>` is adopt-only: observe the persisted session and resume it, failing when the log does not exist. The first wake omits the flag, so the runtime mints the identity and reports it in the `session` event; every later wake names that value and continues the history. A requested id with no stored log is an error rather than a fresh conversation, so a mistyped or stale id cannot silently open an empty history the caller believes it is resuming, and the JSONL store's refusal of an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)) is never on this path. The id is opaque, so the runner validates non-emptiness on the trimmed value and passes the caller's exact string through, whitespace included.
Adoption compares the persisted session's recorded cwd with the process cwd, since sessions are organized per project directory ([project session directories](../../implemented/architecture/2026-07-24-project-session-directories.md)). A mismatch exits 1 with a `dsh:` diagnostic instead of silently continuing a conversation rooted elsewhere, and a session that recorded no cwd is rejected for the same reason. A session running under an agent preset is rejected because this bundle composes no preset roster: resuming it here would run it under the headless tools and prompts instead of the composition its log records. The check reads the preset the log currently records — the creation header advanced by any `agent-preset/selected` event — because a blank session may switch preset after creation while the header stays a creation fact, and a malformed selection record fails closed rather than reading as no preset. A session linked to a parent or subagent — including a user fork — is rejected. A live Agent already holding the requested id is refused outright: its owner may still drive it, and `whenIdle` is not a single-message signal, so the runner cannot own an exclusive interval over it. The resumed log is checked again after the runner's idle wait, so a preset selected in that window is still rejected. A whitespace-only `sessionId` is rejected on both the CLI and the direct-config path. Two live processes cannot write one id; the store's write lease already rejects the second writer. The runner reads the observation through the composed `sessionQuery` service and fails loudly when `--session-id` is requested without it — the observation is how it finds the id it must resume — or when the requested identity would lack the `sessionPersistence` service that makes it durable.
## Consequences
What landed: `src/startup.ts` parses `--json` and `--session-id <id>`, treats an absent or `-` task as "read stdin", and raises the usage error only when stdin is a terminal. `src/index.ts` resolves the task, adopts the named session — or mints a fresh identity when the flag is absent — and wires either the stderr reasoning projection or the new `src/json-stream.ts` projection. `cordis.patch.yml` forwards the two new settings. `package.json` publishes the shared `lib/json-stream-*.js` chunk both entries import, so the installed tarball loads.
- Default mode is unchanged: a text-only run writes one final assistant line to stdout and nothing to stderr, and exit status still follows the terminal reason.
- `--json` stdout parses line by line as JSON, starts with `session`, ends with `final`, and contains no plain text. Stderr carries no reasoning in this mode.
- A step that retries publishes `text` and `thinking` only for the attempt that commits, so a discarded attempt leaves no trace in the stream.
- Two consecutive runs with the same `--session-id` share history, and a requested id with no stored log exits 1 before the task runs. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, that runs under an agent preset, that carries a malformed preset record, or whose identity is already live in the process, exits 1 with a diagnostic.
- A piped task with no positional task is honored, a whitespace-only positional is rejected instead of consuming the pipe, and an interactive invocation without a task still fails with the usage error.
- Unit coverage lands in `packages/bundle/headless/tests/startup.spec.ts`, `tests/headless.spec.ts`, and `tests/json-stream.spec.ts`. The product headless profile expectation test in `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` covers both output modes end to end.
Deferred and open:
- The per-run `--model` override is unimplemented. A later change must respect the session-local selection precedence owned by the Session Controller rather than overriding a stored selection.
- Cold start plus log replay grows with session length, so a long-lived conversation pays more per wake than a fresh one.
- `--json` moves reasoning from stderr to stdout, so a log collector that watches stderr sees nothing on a reasoned run in that mode.
- Bounded `tool_result` payloads hide full output from the supervisor; the 8 KiB string/key cap and the 32 KiB line cap are owned by `src/json-stream.ts` and should stay constants, with the terminal `final` event the only exemption.
## Alternatives considered
**`--verbose` human text on stderr.** A supervisor parses stdout, so a stderr-only projection is invisible to it. Default-mode stderr reasoning already is the human verbose surface.
**Dump raw session events.** They repeat the assembled message beside its deltas, echo `user/message`, and include internal events. Measured on one prompt, pi's delta stream produced 84 lines and 11.7 KB against opencode's 3 lines and 962 B, with roughly a quarter of pi's bytes spent repeating one message across `message_end`, `turn_end`, and `agent_end`.
**A long-lived SDK process instead of one process per wake.** The SDK already speaks structured events and an explicit session identity, but it replaces the one-process-per-wake model the supervisor is built on. Measured cold start for the headless profile is about 0.45 s warm and 1.2 s cold, small against a real turn.
**Let the supervisor mint the session id.** Identity belongs to the runtime that owns the log. The supervisor records what the first event reports.
**Adopt-or-create `--session-id`.** Creating the requested id when no log exists would let a mistyped or stale id silently open an empty history that the supervisor believes it is continuing; the first wake already omits the flag and reads the minted id from the `session` event, so no caller needs `--session-id` to create.
**Task from argv only.** Long prompts exceed `ARG_MAX` and expose the prompt in the process list.
@@ -0,0 +1,100 @@
# Agent Note: Headless 的机器可读运行接口
Status: implemented
[English](2026-09-09-headless-machine-readable-run-surface.md) | 中文
## 问题
`dsh --profile headless` 面向的是人类终端:任务只能通过 argv 传入,stdout 只输出最终一条助手消息,provider 的推理过程流式写到 stderr,而且每次运行都新建一个随机会话。[Headless is a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) 拥有那套传输与完成契约;[headless reasoning progress](../../archived/feature/2026-08-21-headless-reasoning-progress.md) 拥有 stderr 投影。
一个"每次唤醒起一个 headless 进程"的监督进程(例如外部 agent 运行时)需要三样该契约没有提供的东西。它需要通过私有管道而不是 argv 传入任务,因为长提示词会超出参数上限,而 argv 对其他进程可见。它需要一条机器可读的流,把助手文本、推理、工具调用与结果、轮次边界和用量区分开,因为抓取 stderr 只能拿到推理,而 stdout 最后一行拿不到任何工具活动。它需要一个精确的会话身份,以便在下一次唤醒时传回去,因为每次进程都新建随机会话意味着无法连续。
## 决策
`dsh-headless` bundle 拥有一个可选的机器可读运行接口。默认调用保持原有契约不变:stdout 输出一条最终助手消息,推理走 stderr,当且仅当终端 `turn/end` 原因为 `completed` 时退出码为 0。
三项新增扩展 [Apps own their command lines](../../archived/architecture/2026-08-06-app-owned-command-line.md) 确立的 app 自有命令行:
- `--json` 把 stdout 负载换成逐行 JSON 运行事件。推理变成一条事件而不再写 stderr,因此该模式下 stderr 只承载 `dsh:` 诊断。
- `--session-id <id>` 选定精确的会话身份:采用具有该 id 的持久化会话,不存在时就失败。不带该 flag 时,运行仍像以前一样生成 `session-<uuid>`。
- 没有位置参数、或者位置参数为 `-` 时,任务文本改从 stdin 读取。
每次运行的 `--model` 覆盖被明确排除在范围之外;组合默认模型仍然权威。
产品改动限于 `packages/bundle/headless`:`src/startup.ts`、`src/index.ts`、新增的 `src/json-stream.ts`、包清单与 `tsconfig.json`,以及测试。围绕它,产品 profile 的期望测试位于 `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts`,端到端覆盖两种输出模式,并给 `packages/test-support/loader-smoke` harness 增加了一个可选的调用方自有 `cwd`,让两次唤醒共享同一个世界;`scripts/check-workspace-constraints.ts` 与包清单负责发布两个入口共同引用的共享 chunk `lib/json-stream-*.js`,`pnpm-lock.yaml` 则记录新增的 `@deepseek-ai/dsh-session-query` workspace 链接。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。
### 命令行契约
```text
dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
```
任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。只有空白的位置参数本身就属于用法错误,即使 stdin 不是终端也一样,因此误传的空白参数绝不会消费管道内容;完全缺失任务时,仅在 stdin 是终端时属于用法错误。单独的 `-` 是唯一的 stdin 标记:把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。管道任务会原样发送,包括结尾换行。在 `--json` 模式下,所有用法错误——包括 commander 自身的语法拒绝,例如未知选项或选项缺少取值——都会在进程退出前写出 `error` 事件,因为 runner 从未挂载来写它;事件 message 省略 commander 的 `error: ` 前缀,使同一事件类型只承载一种消息形态。
`--json` 只改变 stdout 负载和推理投影的去向。退出码、关闭顺序、会话 flush 和持久化会话日志都不变,因此监督进程对一次运行的分类方式与现在完全一致。
### 事件流
`--json` 向 stdout 每行写一个 JSON 对象,不写其他内容。词汇表是会话事件日志的投影,而不是日志本身。
| `type` | 字段 | 发出时机 |
|---|---|---|
| `session` | `sessionId`、`cwd` | 第一行,先于任何模型输出 |
| `status` | `phase`(`turn_start`、`step_start`、`step_end`、`turn_end`)、`turn`、`step`、`usage`、`reason` | 每个边界一条 |
| `text` | `text` | 一个已提交的助手文本块 |
| `thinking` | `text` | 一个已提交的推理块 |
| `tool_call` | `callId`、`tool`、`input` | 每次调用一条 |
| `tool_result` | `callId`、`status`、`result` | 每次追加的结果一条 |
| `error` | `message` | runner 在轮次之外抛出的失败 |
| `final` | `text` | 最后一行 |
投影规则:
- 文本与推理只从已提交的 `assistant/message` 投影,绝不来自实时的 attempt 增量。被重试或丢弃的 attempt 会追加 `assistant/attempt`,投影直接忽略,因此事件流永远不会承载持久化日志中不存在的内容([只在提交点发布状态](../../../../packages/AGENTS.md))。
- 每个已提交的内容块按内容顺序变成恰好一条 `text` 或 `thinking` 事件;`tool-call` 块不投影,因为 `tool/call` 事件已经拥有它。`user/message` 回显和内部会话事件(标题、模型选择、投影、检查点、目标、子 agent)都不投影。
- `tool/result` 仅在其 `surfaceOp` 为 `append` 时投影。压缩对旧结果的替换属于历史,投影它会产生没有对应 `tool_call` 的 call id。
- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,单条序列化事件行(含换行)限制在 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 `type` 与 `truncated`,嵌套达到 64 层及以上的负载会在该深度被截断,因此任何合法输入都不会让限界递归溢出。进程级 `error` 事件同样受限;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter;空工具参数字符串会投影为 `{}`,与执行器保持一致;JSON 无法往返的参数(例如溢出为 `Infinity` 的 `1e400`)保留原始文本,而不是 `JSON.stringify` 会报告的 `null`。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
- 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 `final` 结束且没有 `error` 事件,因此即使事件流格式良好,监督进程也要用退出码与 `turn_end` 原因来分类该次运行。
- `usage` 仅在该步每一次 attempt 都上报了样本时出现在 `step_end` 上,并累计这些样本——包括仅在被丢弃的 `assistant/attempt` 流中留下用量样本的重试——因此部分汇总不会被当作精确总量发布。
- 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。
### 会话身份
身份由运行时拥有。不带 `--session-id` 的运行生成 `session-<uuid>`,并在第一条事件里报告它。监督进程保存该值,并在下一次唤醒时传回。
`--session-id <id>` 是只采用:先观察持久化会话并 resume,日志不存在时失败。首轮不传该 flag,由运行时生成身份并在 `session` 事件里报告;后续每一轮都用该值指名并续接历史。请求的 id 没有持久化日志时报错,而不是开一段新会话,因此写错或过期的 id 不会静默开出一段调用方自以为在续接的空历史,JSONL 存储拒绝已存在日志 id 的问题(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))也不会出现在这条路径上。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。
采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。运行在 agent preset 下的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志当前记录的组合。该检查读取日志当前记录的 preset——创建 header 再叠加任何 `agent-preset/selected` 事件——因为空白会话可能在创建后切换 preset,而 header 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。本进程已存在持有请求 id 的存活 Agent 时直接拒绝:它的 owner 可能仍在驱动它,而 `whenIdle` 不是单条消息的完成信号,runner 无法对它取得独占区间。resume 后的日志会在 runner 等待 idle 后再次检查,因此该窗口内选中的 preset 仍会被拒绝。纯空白的 `sessionId` 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——观察结果正是它找到待 resume id 的途径;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败。
## 后果
实际落地:`src/startup.ts` 解析 `--json` 与 `--session-id <id>`,把缺失或为 `-` 的任务视为"从 stdin 读取",并且只在 stdin 是终端时抛出用法错误。`src/index.ts` 解析任务、采用指名的会话——未传 flag 时生成新身份——并接上 stderr 推理投影或新的 `src/json-stream.ts` 投影。`cordis.patch.yml` 转发这两个新设置。`package.json` 发布两个入口共同引用的共享 chunk `lib/json-stream-*.js`,因此安装后的 tarball 可以加载。
- 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。
- `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。
- 发生重试的步骤只为最终提交的 attempt 发布 `text` 与 `thinking`,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。
- 两次连续的相同 `--session-id` 运行共享历史,而请求一个没有持久化日志的 id 会在任务运行前退出 1。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或本进程已存在同 id 存活 Agent 的运行都以诊断退出 1。
- 无位置参数但 stdin 有管道输入时任务被采纳,只有空白的位置参数会被拒绝而不会消费管道,交互式无任务调用仍以用法错误失败。
- 单元覆盖落在 `packages/bundle/headless/tests/startup.spec.ts`、`tests/headless.spec.ts` 与 `tests/json-stream.spec.ts`。`apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` 的产品 headless profile 期望测试端到端覆盖两种输出模式。
延期与未决:
- 每次运行的 `--model` 覆盖尚未实现。后续改动必须尊重 Session Controller 拥有的会话局部选择优先级,而不是覆盖已保存的选择。
- 冷启动加上日志重放会随会话变长而增长,因此长会话每次唤醒的代价高于新会话。
- `--json` 把推理从 stderr 移到 stdout,因此只监听 stderr 的日志收集器在该模式的有推理运行上什么都看不到。
- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 字符串/键上限与 32 KiB 行上限由 `src/json-stream.ts` 拥有,应保持为常量,终止 `final` 事件是唯一的例外。
## 备选方案
**`--verbose` 人类可读文本写到 stderr。** 监督进程解析的是 stdout,只落在 stderr 的投影对它不可见。默认模式的 stderr 推理已经是人类可读的 verbose 面。
**直接倾倒原始会话事件。** 它们在增量之外重复整条已组装消息,回显 `user/message`,还夹带内部事件。在同一条提示词上实测,pi 的增量流产生 84 行、11.7 KB,而 opencode 是 3 行、962 B;pi 大约四分之一的字节花在把同一条消息在 `message_end`、`turn_end`、`agent_end` 里重复三遍。
**用长驻 SDK 进程代替每次唤醒一个进程。** SDK 已经有结构化事件和显式的会话身份语义,但它会替换掉监督进程所依赖的"一次唤醒一个进程"模型。实测 headless profile 的冷启动约为热态 0.45 s、冷态 1.2 s,相对一个真实轮次很小。
**让监督进程生成会话 id。** 身份属于拥有日志的运行时。监督进程记录第一条事件报告的值即可。
**采用或创建语义的 `--session-id`。** 在日志不存在时创建所请求的 id,会让写错或过期的 id 静默开出一段监督进程自以为在续接的空历史;首轮本就不传该 flag 并从 `session` 事件读到生成的 id,因此没有任何调用方需要用 `--session-id` 来创建。
**任务只走 argv。** 长提示词会超出 `ARG_MAX`,并且把提示词暴露在进程列表里。
@@ -1,6 +1,7 @@
import { readFile, readdir, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
@@ -251,6 +252,117 @@ describe('headless stream-json snapshots', () => {
expect(result.stderr).toBe(await readFile(headlessReasoningExpected, 'utf8'))
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('projects the same run as JSON events under a generated session identity', async () => {
const task = 'Prove the machine-readable product headless profile path.'
const result = await runLoaderSmoke({
label: 'product headless profile json snapshot',
tempDirPrefix: 'headless-snapshot-profile-json-',
binScript: dshBinScript,
configPath: headlessOverlayPath,
binArgs: [
'--profile', 'headless', '--patch', headlessOverlayPath,
'--json', task,
],
tsconfigPath,
env: {
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_DISABLED: '1',
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
})
const events = result.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject)
expect(events[0]).toMatchObject({ type: 'session' })
expect(events[0]?.sessionId).toMatch(/^session-/)
expect(typeof events[0]?.cwd).toBe('string')
expect(events.at(-1)).toMatchObject({ type: 'final', text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP' })
expect(events.map(event => event.type)).toContain('thinking')
expect(events.map(event => event.type)).toContain('tool_call')
expect(events.map(event => event.type)).toContain('tool_result')
expect(events.map(event => event.type)).not.toContain('error')
expect(result.stderr).toBe('')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fails the JSON run when --session-id names no stored Session', async () => {
const result = await runLoaderSmoke({
label: 'product headless profile unknown session',
tempDirPrefix: 'headless-snapshot-profile-unknown-session-',
binScript: dshBinScript,
configPath: headlessOverlayPath,
binArgs: [
'--profile', 'headless', '--patch', headlessOverlayPath,
'--json', '--session-id', 'headless-unknown-session', 'Continue the conversation.',
],
tsconfigPath,
expectedExitCode: 1,
env: {
DSH_TELEMETRY_DISABLED: '1',
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
})
const events = result.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject)
expect(events).toEqual([{
type: 'error',
message: 'session "headless-unknown-session" does not exist; omit --session-id to start a new Session',
}])
expect(result.stderr).toContain('omit --session-id to start a new Session')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('resumes one persisted Session across two real --session-id wakes', async () => {
const firstTask = 'Record the first wake of the resume proof.'
const secondTask = 'Continue from the first wake of the resume proof.'
const env = {
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_DISABLED: '1',
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
}
const cwd = await mkdtemp(join(tmpdir(), 'headless-session-resume-'))
try {
const first = await runLoaderSmoke({
label: 'product headless profile resume first wake',
cwd,
binScript: dshBinScript,
configPath: headlessOverlayPath,
binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, '--json', firstTask],
tsconfigPath,
env,
})
const firstEvents = first.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject)
expect(firstEvents[0]).toMatchObject({ type: 'session' })
const sessionId = firstEvents[0]?.sessionId
if (typeof sessionId !== 'string') throw new Error('the first wake reported no Session identity')
const second = await runLoaderSmoke({
label: 'product headless profile resume second wake',
cwd,
binScript: dshBinScript,
configPath: headlessOverlayPath,
binArgs: [
'--profile', 'headless', '--patch', headlessOverlayPath,
'--json', '--session-id', sessionId, secondTask,
],
tsconfigPath,
env,
inspect: async (inspected) => {
const logs = await persistedLogs(inspected, join(inspected, '.dsh', 'sessions'))
expect(logs).toHaveLength(1)
const content = logs[0]?.content ?? ''
expect(content).toContain(firstTask)
expect(content).toContain(secondTask)
},
})
const secondEvents = second.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject)
expect(secondEvents[0]).toMatchObject({ type: 'session', sessionId })
expect(secondEvents.at(-1)).toMatchObject({
type: 'final',
text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP',
})
} finally {
await rm(cwd, { recursive: true, force: true })
}
}, LOADER_SMOKE_TEST_TIMEOUT_MS * 2)
it('prints a terminal model failure through the product headless profile command', async () => {
const result = await runLoaderSmoke({
label: 'product headless profile model failure snapshot',
+2 -2
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 docs/config-catalog.md
config-catalog.md: 2b0e61f83320b53be5b242cd1934994faa8c40b0
config-catalog.zh.md: ef5e50cb6db80c125e04cdaf70b6e5acf0137cc7
config-catalog.md: 5dc9a5a23d38a812626e2ac42a4c32fc8671ccc8
config-catalog.zh.md: caf689325567af3480d6aab6da080d227b978093
+8 -4
View File
@@ -817,14 +817,18 @@ Source: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.
Requires: `agentDefaultModel` · `agents` · `sessions`
```ts config-catalog
/** Plugin config: the task resolved from this app's injected provider service. */
/** Plugin config: the task and run options resolved from this app's injected provider service. */
export interface Config {
/** The prompt text for the single run. */
task: string
/** The prompt text for the single run; absent when the task arrives on stdin. */
task?: string
/** Exact Session identity to adopt; absent for a fresh random identity. An id with no stored Session fails. */
sessionId?: string
/** Whether stdout carries the machine-readable event stream instead of final text. */
json?: boolean
}
```
Source: [`packages/bundle/headless/src/index.ts:34`](../packages/bundle/headless/src/index.ts)
Source: [`packages/bundle/headless/src/index.ts:41`](../packages/bundle/headless/src/index.ts)
<a id="deepseek-aidsh-hooks-claude-code"></a>
+8 -4
View File
@@ -819,14 +819,18 @@ export interface Config {
需要:`agentDefaultModel` · `agents` · `sessions`
```ts config-catalog
/** Plugin config: the task resolved from this app's injected provider service. */
/** Plugin config: the task and run options resolved from this app's injected provider service. */
export interface Config {
/** The prompt text for the single run. */
task: string
/** The prompt text for the single run; absent when the task arrives on stdin. */
task?: string
/** Exact Session identity to adopt; absent for a fresh random identity. An id with no stored Session fails. */
sessionId?: string
/** Whether stdout carries the machine-readable event stream instead of final text. */
json?: boolean
}
```
来源:[`packages/bundle/headless/src/index.ts:34`](../packages/bundle/headless/src/index.ts)
来源:[`packages/bundle/headless/src/index.ts:41`](../packages/bundle/headless/src/index.ts)
<a id="deepseek-aidsh-hooks-claude-code"></a>
+2 -2
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 docs/event-producer-consumer.md
event-producer-consumer.md: 5f06361a1ad423c7c62cee6089298325c60ef96a
event-producer-consumer.zh.md: 6691e24c1ec3fae9f369f6362861cf9bf1e9fa37
event-producer-consumer.md: 239b93480a70e6b4d2107f7bdd177019d501fa5e
event-producer-consumer.zh.md: 94ad41bfedcdbf4c63af5f9f346213fb0973cf34
+1 -1
View File
@@ -50,7 +50,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
+1 -1
View File
@@ -52,7 +52,7 @@
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
+2 -2
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 docs/module-graph.md
module-graph.md: 9e1f873b5690838c8a680ddf4ee3dd9c009cec80
module-graph.zh.md: 81abb4bb4959ad72901bfda270e08dccd237c256
module-graph.md: 403636b274beac322de092deea39defb56a20c56
module-graph.zh.md: 68fa1059560ac908d4004dbd61313bbd3f99a903
+6 -5
View File
@@ -652,10 +652,6 @@ flowchart TD
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_headless --> pkg_agent
pkg_headless --> pkg_agent_default_model
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_compaction --> pkg_brand
pkg_compaction --> pkg_commands
pkg_compaction --> pkg_invariants
@@ -1006,6 +1002,11 @@ flowchart TD
pkg_tool_session_query --> pkg_system_prompt
pkg_tool_session_query --> pkg_timeout
pkg_tool_session_query --> pkg_tools
pkg_headless --> pkg_agent
pkg_headless --> pkg_agent_default_model
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_headless --> pkg_session_query
pkg_compaction_basic --> pkg_agent
pkg_compaction_basic --> pkg_commands
pkg_compaction_basic --> pkg_compaction
@@ -1370,7 +1371,6 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) |
@@ -1431,6 +1431,7 @@ flowchart TD
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
+6 -5
View File
@@ -654,10 +654,6 @@ flowchart TD
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_headless --> pkg_agent
pkg_headless --> pkg_agent_default_model
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_compaction --> pkg_brand
pkg_compaction --> pkg_commands
pkg_compaction --> pkg_invariants
@@ -1008,6 +1004,11 @@ flowchart TD
pkg_tool_session_query --> pkg_system_prompt
pkg_tool_session_query --> pkg_timeout
pkg_tool_session_query --> pkg_tools
pkg_headless --> pkg_agent
pkg_headless --> pkg_agent_default_model
pkg_headless --> pkg_llm
pkg_headless --> pkg_session
pkg_headless --> pkg_session_query
pkg_compaction_basic --> pkg_agent
pkg_compaction_basic --> pkg_commands
pkg_compaction_basic --> pkg_compaction
@@ -1372,7 +1373,6 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) |
@@ -1433,6 +1433,7 @@ flowchart TD
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
+2 -2
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: 98a9cb2294d8b05a40287c990b3ff53f755747cf
README.zh.md: 99bf90845bed1a7601ca8a70cfff98567ea364f8
README.md: 859b39ed3bc14d7d9f85db179bcbe712918c4171
README.zh.md: 550036e7fa0650781628e709b3429cc005b7ac66
+36 -16
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-headless` runs one dsh task from the command line and prints the final answer, then exits — no GUI, no server, no browser. Type `dsh --profile headless "run the tests"` and the agent works through the task with the same model, tools, and safety defaults as every other surface. It is ideal for scripts, CI, and one-off jobs: the process opens no ports and leaves nothing running behind. The exit code tells you the outcome — 0 when the task completed, 1 when it aborted or errored. The main boundary: one task per invocation, with no interactive follow-up.
`dsh-headless` runs one dsh task from the command line and prints the final answer, then exits — no GUI, no server, no browser. Type `dsh --profile headless "run the tests"` and the agent handles it with the same model, tools, and safety defaults as every other surface. It suits scripts, CI, and one-off jobs: it opens no ports and leaves nothing running behind. It also offers a JSON event stream (`--json`) and `--session-id` to resume a conversation. Exit code 0 means the task completed; 1 means it aborted or errored. The boundary: one task per invocation, no interactive follow-up.
## Table of Contents
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Run one task, get the final answer, and exit. The task is the command line itself, so the whole invocation is the smallest working example.
Run one task, get the final answer, and exit. The task is the command-line argument, or stdin when you omit it; the whole invocation is the smallest working example.
### Running a one-shot task
@@ -33,21 +33,37 @@ Run one task, get the final answer, and exit. The task is the command line itsel
dsh --profile headless "run the tests"
```
The agent works through the task, streams each non-empty provider reasoning delta to stderr under a `dsh: reasoning:` heading, then prints the final answer on stdout and exits. Consecutive reasoning deltas stay in one section, and the runner closes that section before later output when the provider supplied no trailing newline. A successful run without reasoning keeps stderr empty; a failure exits 1 and prints `dsh: <code>: <message>` to stderr. A missing or blank task is rejected before anything runs. The task text is supplied through the single `task` setting:
The agent works through the task, streams each non-empty provider reasoning delta to stderr under a `dsh: reasoning:` heading, then prints the final answer on stdout and exits. Consecutive reasoning deltas stay in one section, and the runner closes that section before later output when the provider supplied no trailing newline. A successful run without reasoning keeps stderr empty; a failure exits 1 and prints `dsh: <code>: <message>` to stderr. The task comes from the positional argument, or from stdin when the argument is omitted or is a lone `-`; a blank positional argument or an empty pipe is rejected before anything runs. A positional task is used as-is and stdin is not read, so put the whole prompt in the pipe when you want piped input; a piped task is sent verbatim, its trailing newline included.
```sh
{ echo "Summarize these changes:"; git diff --stat; } | dsh --profile headless
```
The task and run options are supplied through three settings:
| Field | Default | Meaning |
|---|---|---|
| `task` | required | The task text for the single run |
| `task` | stdin | The task text; stdin supplies it when omitted or `-` |
| `sessionId` | `session-<uuid>` | Exact Session identity to adopt; an unknown id fails |
| `json` | `false` | Project the run as newline-delimited events on stdout |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-headless) is the exhaustive source for every accepted field and its JSDoc.
### Choosing the session identity
Every invocation defaults to a fresh `session-<uuid>` identity, which `--json` reports in its opening `session` event. Pass `--session-id <id>` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. Adoption is scoped to the current working directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
### Machine-readable output
`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream; they arrive when the step commits, not per token, and default-mode stderr reasoning remains the only live text channel. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string and object key is capped at 8 KiB and flagged with `truncated`, and one event line, its newline included, is capped at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`, while a payload nested 64 levels or deeper is cut at that depth. An empty tool-argument string projects as `{}`, matching what the executor runs, while arguments that JSON cannot round-trip — an overflowing number such as `1e400` — keep their raw text rather than the `null` that `JSON.stringify` would report. A failure the runner raises outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line; a profile whose own plugins fail to load exits before the runner mounts, so that case keeps only the loader's stderr diagnostics. A turn that fails in-turn still ends with a `final` event (often empty) and no `error` event, so a well-formed stream can still describe a failed run: treat exit code 1 and the `turn_end` reason as the failure signal.
### When to use it
Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick answers from a terminal. Avoid it when you need a multi-turn interactive session or a GUI; the browser surface ([dsh-web-app](../web-app/README.md)) serves that. The process stays alive only for the run, opens no listening port, and exits on its own, so it fits pipelines that wait on the process.
Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick answers from a terminal. Avoid it when you need a multi-turn interactive session or a GUI; the browser surface ([dsh-web-app](../web-app/README.md)) serves that. The process stays alive only for the run, opens no listening port, and exits on its own, so it fits pipelines that wait on the process. When a supervisor needs progress rather than just the answer, `--json` gives it the event stream and `--session-id` lets a later invocation continue the same conversation.
### Help and task errors
`dsh --profile headless --help` prints the command's help text and exits without running anything. A missing or whitespace-only task is a usage error: nothing runs and the process exits 1.
`dsh --profile headless --help` prints the command's help text and exits without running anything. A whitespace-only positional task is a usage error on its own — nothing runs and the process exits 1, even when stdin is not a terminal, so an accidental blank argument never consumes a pipe. A task that is absent entirely is a usage error only when stdin is a terminal; otherwise the runner reads the task from stdin and rejects an empty result the same way. A lone `-` is the only stdin marker; mixing it with other task words is a usage error rather than a task that starts with a dash. In `--json` mode every usage error — including commander's own grammar rejections such as an unknown option or a missing option value — also writes an `error` event to stdout before the process exits, so a line-oriented supervisor sees a well-formed stream even when the runner never mounts; the event `message` carries the text without commander's `error: ` prefix.
-----
@@ -57,29 +73,31 @@ Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick
<details>
<summary>Implementation internals — click to expand</summary>
The runner is a direct driver over the core API carrier: it creates one fresh Agent through the registry and folds the owned durable event interval into one process-level outcome.
The runner is a direct driver over the core API carrier: it resolves the Agent identity — a fresh `session-<uuid>` by default, or the persisted Session `--session-id` names — and folds the owned durable event interval into one process-level outcome.
### Run flow
The runner awaits the complete application (`ctx.get('loader')?.await()`) so the composed tools and adapters are not half-mounted, reads the shared [`agentDefaultModel`](../../core/agent-default-model/README.md) selection, creates one fresh persisted Agent with that provider and model, and submits the task as an ordinary user message. It streams that Agent's non-empty reasoning deltas to stderr, waits for quiescence, then flushes the Session and folds the owned interval (`firstSeq` onward) into the last non-empty `assistant/message` text and final `turn/end` reason. It writes the final text to stdout and requests exit.
The runner awaits the complete application (`ctx.get('loader')?.await()`) so the composed tools and adapters are not half-mounted, reads the shared [`agentDefaultModel`](../../core/agent-default-model/README.md) selection, resolves the task from config or stdin, then resolves the Agent identity: a fresh `session-<uuid>` by default, or the persisted Session `--session-id` names, which it adopts through [`sessionQuery`](../../session-query/session-query/README.md) and refuses when no log exists. It submits the task as an ordinary user message. Without `--json` it streams that Agent's non-empty reasoning deltas to stderr; with `--json` it projects the run instead. It waits for quiescence, then flushes the Session and folds the owned interval (`firstSeq` onward) into the last non-empty `assistant/message` text and final `turn/end` reason. It writes the final text to stdout (or the `final` event) and requests exit.
### Patch surface over base
The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts PTC mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config.
The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts PTC mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument and the `--session-id`/`--json` options, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task and run options from lazy config.
### Exit mapping
A completed final `turn/end` exits 0; any other outcome — aborted, error, or no turn in the owned interval — exits 1. An `error` reason also writes `dsh: <code>: <message>` to stderr. A direct driver failure (for example, Agent creation) writes `dsh: <message>` to stderr and exits 1.
A completed final `turn/end` exits 0; any other outcome — aborted, error, or no turn in the owned interval — exits 1. An `error` reason also writes `dsh: <code>: <message>` to stderr. A direct driver failure (for example, Agent creation or an unusable `--session-id`) writes `dsh: <message>` to stderr and exits 1, and in `--json` mode also emits an `error` event.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | The `headless-runner` plugin: run flow, output contract, exit mapping |
| [`src/startup.ts`](src/startup.ts) | The `headless-startup` provider: task positional and `--help` |
| [`src/index.ts`](src/index.ts) | The `headless-runner` plugin: run flow, session resolution, output contract, exit mapping |
| [`src/startup.ts`](src/startup.ts) | The `headless-startup` provider: task positional, `--session-id`, `--json`, and `--help` |
| [`src/json-stream.ts`](src/json-stream.ts) | The `--json` projection: event vocabulary, commit-point emission, string bounding |
| [`cordis.patch.yml`](cordis.patch.yml) | The one-shot patch over `dsh-base` |
| — | No runtime invariant companion is published; the runner's observable contract (provider reasoning on stderr, final text on stdout, exit code by turn-end reason) is process-level and owned by the launcher e2e; it registers nothing and holds no mutable relation to audit inside the tree. |
| [`tests/headless.spec.ts`](tests/headless.spec.ts) | Run flow, aggregation, flush, and exit mapping |
| [`tests/headless.spec.ts`](tests/headless.spec.ts) | Run flow, aggregation, flush, session adoption, and exit mapping |
| [`tests/json-stream.spec.ts`](tests/json-stream.spec.ts) | Projection ordering, commit-point emission, bounding, and disposal |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree |
### Invariant ownership
@@ -121,9 +139,11 @@ These limits tell you when headless does not fit and what it needs from the `dsh
- **One task per run** — after the task is answered the process exits; there is no interactive follow-up, so split multi-step work into separate runs.
- **Runs through the `dsh` launcher** — starting the headless profile another way fails at startup, because only the launcher can request the process exit.
- **No pre-token heartbeat** — stderr stays silent until the provider emits a non-empty reasoning delta; a delayed first token exposes no earlier progress signal.
- **Reasoning enters stderr logs** — redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when needed.
- **Only reasoning and the final answer are printed** — a run without an assistant message prints an empty stdout line and exits 1; intermediate tool output is not printed.
- **No pre-token heartbeat** — in default mode stderr stays silent until the provider emits a non-empty reasoning delta; a delayed first token exposes no earlier progress signal.
- **Reasoning enters stderr logs** — in default mode, redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when needed.
- **Default stdout carries only the final answer** — a run without an assistant message prints an empty stdout line and exits 1; intermediate tool output is not printed unless you opt into `--json`.
- **Adoption is cwd-, ownership-, and preset-scoped** — `--session-id` refuses a Session recorded in another working directory, one that recorded no working directory, one that is a subagent or forked session, or one that runs under an agent preset this profile does not compose or whose preset record is malformed, and requires the composed Session query and persistence services; an identity already live in the process is refused too, because the runner cannot own an exclusive run interval over it.
- **The event stream is a projection, not the log** — `--json` caps every string except the terminal `final` at 8 KiB and omits events the projection does not model, so it is not a lossless copy of the Session log.
<a id="dev-note"></a>
### Dev Note
+36 -16
View File
@@ -9,7 +9,7 @@ kind: "package-bundle"
## 概述
`dsh-headless` 从命令行运行一个 dsh 任务并打印最终答案,然后退出——没有 GUI、没有服务器、没有浏览器。输入 `dsh --profile headless "run the tests"`,agent(智能体)会以与所有其他表层相同的模型、工具与安全默认值完成该任务。它非常适合脚本、CI 与一次性任务:进程不打开任何端口,也不会留下任何后台运行的东西。退出码告诉你结果——任务完成时为 0,中止或出错时为 1。主要边界:每次调用只运行一个任务,没有交互式后续。
`dsh-headless` 从命令行运行一个 dsh 任务并打印最终答案,然后退出——没有 GUI、没有服务器、没有浏览器。输入 `dsh --profile headless "run the tests"`,agent(智能体)会以与所有其他表层相同的模型、工具与安全默认值完成该任务。它非常适合脚本、CI 与一次性任务:进程不打开任何端口,也不会留下任何后台运行的东西。监督进程还可以通过按行 JSON 事件流(`--json`)驱动它,并用该事件流报告的标识(`--session-id`)在同一段对话上继续唤醒。退出码告诉你结果——任务完成时为 0,中止或出错时为 1。主要边界:每次调用只运行一个任务,没有交互式后续。
## 目录
@@ -25,7 +25,7 @@ kind: "package-bundle"
<a id="use-this-package"></a>
## 使用本包
运行一个任务,获得最终答案,然后退出。任务就是命令行本身,因此整条命令就是最小的可运行示例。
运行一个任务,获得最终答案,然后退出。任务就是命令行参数,省略时则来自 stdin;整条命令就是最小的可运行示例。
### 运行一次性任务
@@ -33,21 +33,37 @@ kind: "package-bundle"
dsh --profile headless "run the tests"
```
agent 会完成该任务,把提供方的每个非空推理(reasoning)增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: <code>: <message>` 向 stderr 写入错误。缺失或空白任务会在任何执行开始之前被拒绝。任务文本通过唯一的 `task` 设置提供:
agent 会完成该任务,把提供方的每个非空推理(reasoning)增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: <code>: <message>` 向 stderr 写入错误。任务来自位置参数,参数省略或为单独的 `-` 时则来自 stdin;空白位置参数或空管道会在任何执行开始之前被拒绝。位置参数会原样作为任务,stdin 不会被读取,因此想让管道内容进入提示词时,请把完整提示写进管道;管道任务会原样发送,包括结尾换行。
```sh
{ echo "Summarize these changes:"; git diff --stat; } | dsh --profile headless
```
任务与运行选项通过三个设置提供:
| 字段 | 默认值 | 含义 |
|---|---|---|
| `task` | 必填 | 单次运行的任务文本 |
| `task` | stdin | 任务文本;省略或传 `-` 时由 stdin 提供 |
| `sessionId` | `session-<uuid>` | 要沿用的精确 Session 标识;未知 id 会失败 |
| `json` | `false` | 把本次运行投影为 stdout 上的按行 JSON 事件 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)是所有受支持字段及其 JSDoc 的完整真源。
### 选择 Session 标识
每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
### 机器可读输出
`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流;它们在步骤提交时到达,而不是逐 token 到达,默认模式的 stderr 推理仍是唯一的实时文本通道。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串与对象键上限为 8 KiB,超出时标记 `truncated`,单条事件行(含换行)上限为 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 `type` 与 `truncated`,嵌套达到 64 层及以上的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致;而 JSON 无法往返的参数——例如溢出为 `Infinity` 的数字 `1e400`——会保留原始文本,而不是 `JSON.stringify` 会报告的 `null`。runner 在轮次之外抛出的失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行;若 profile 自身的插件加载失败,进程会在 runner 挂载前退出,该情形只保留 loader 的 stderr 诊断。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。
### 何时使用
在脚本化或自动化的 dsh 运行中使用 headless——CI 步骤、批处理任务、从终端快速获取答案。当需要多轮交互会话或 GUI 时请避免它;浏览器表层([dsh-web-app](../web-app/README.zh.md))负责这类场景。进程只为本次运行而存活,不打开监听端口,并且自行退出,因此适合等待进程结束的流水线。
在脚本化或自动化的 dsh 运行中使用 headless——CI 步骤、批处理任务、从终端快速获取答案。当需要多轮交互会话或 GUI 时请避免它;浏览器表层([dsh-web-app](../web-app/README.zh.md))负责这类场景。进程只为本次运行而存活,不打开监听端口,并且自行退出,因此适合等待进程结束的流水线。当监督进程需要进度而不只是答案时,`--json` 提供事件流,`--session-id` 则让后续调用继续同一段对话。
### 帮助与任务错误
`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。
`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。只有空白的位置参数本身就属于用法错误——什么都不运行,进程退出 1,即使 stdin 不是终端也一样,因此误传的空白参数绝不会消费管道内容。完全缺失任务时,仅在 stdin 是终端时属于用法错误;stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。单独的 `-` 是唯一的 stdin 标记;把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,所有用法错误——包括 commander 自身的语法拒绝,例如未知选项或选项缺少取值——都会在进程退出前向 stdout 写出 `error` 事件,因此按行解析的监督进程即使 runner 从未挂载也能看到格式良好的事件流;事件 `message` 中的文本不带 commander 的 `error: ` 前缀。
-----
@@ -57,29 +73,31 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
<details>
<summary>实现细节——点击展开</summary>
runner 是核心 API 载体之上的直接驱动器:它通过注册表创建一个全新的 Agent,并把所属的持久化事件区间折叠成一个进程级结果。
runner 是核心 API 载体之上的直接驱动器:它确定 Agent 标识——默认是全新的 `session-<uuid>`,或 `--session-id` 指名的持久化 Session——并把所属的持久化事件区间折叠成一个进程级结果。
### 运行流程
runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组合的工具与适配器不会半挂载,读取共享的 [`agentDefaultModel`](../../core/agent-default-model/README.zh.md) 选择,用该提供方与模型创建一个全新的持久化 Agent,并把任务作为普通用户消息提交。它把该 Agent 的非空推理增量流式写入 stderr、等待完全停稳,然后对会话执行 flush,并把所属区间(从 `firstSeq` 起)折叠为最后一条非空 `assistant/message` 文本与最终 `turn/end` 原因。最后,它把最终文本写入 stdout 并请求退出。
runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组合的工具与适配器不会半挂载,读取共享的 [`agentDefaultModel`](../../core/agent-default-model/README.zh.md) 选择,从配置或 stdin 解析任务,然后确定 Agent 标识:默认是全新的 `session-<uuid>`,或是 `--session-id` 指名的持久化 Session——通过 [`sessionQuery`](../../session-query/session-query/README.zh.md) 沿用,日志不存在时拒绝。它把任务作为普通用户消息提交。不带 `--json` 时,它把该 Agent 的非空推理增量流式写入 stderr;带 `--json` 时改为投影本次运行。它等待完全停稳,然后对会话执行 flush,并把所属区间(从 `firstSeq` 起)折叠为最后一条非空 `assistant/message` 文本与最终 `turn/end` 原因。最后,它把最终文本写入 stdout(或 `final` 事件)并请求退出。
### 基于 base 的 patch 内容
patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR(热模块替换)行,把 PTC mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。
patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR(热模块替换)行,把 PTC mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数与 `--session-id`/`--json` 选项、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务与运行选项。
### 退出映射
最终 `turn/end` 完成时退出码为 0;任何其他结果——aborted、error,或所属区间内没有轮次——退出码为 1。结束原因为 `error` 时还会向 stderr 写入 `dsh: <code>: <message>`。直接驱动器失败(例如 Agent 创建失败)向 stderr 写入 `dsh: <message>` 并退出 1。
最终 `turn/end` 完成时退出码为 0;任何其他结果——aborted、error,或所属区间内没有轮次——退出码为 1。结束原因为 `error` 时还会向 stderr 写入 `dsh: <code>: <message>`。直接驱动器失败(例如 Agent 创建失败或不可用的 `--session-id`)向 stderr 写入 `dsh: <message>` 并退出 1,且在 `--json` 模式下额外发出一个 `error` 事件。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `headless-runner` 插件:运行流程、输出约定、退出映射 |
| [`src/startup.ts`](src/startup.ts) | `headless-startup` 提供方:任务位置参数与 `--help` |
| [`src/index.ts`](src/index.ts) | `headless-runner` 插件:运行流程、Session 解析、输出约定、退出映射 |
| [`src/startup.ts`](src/startup.ts) | `headless-startup` 提供方:任务位置参数、`--session-id`、`--json` 与 `--help` |
| [`src/json-stream.ts`](src/json-stream.ts) | `--json` 投影:事件词汇、提交点发射、字符串限长 |
| [`cordis.patch.yml`](cordis.patch.yml) | 叠加在 `dsh-base` 之上的一次性 patch |
| — | 不发布运行时不变式伴生入口;runner 的可观察约定(stderr 中的提供方推理、stdout 中的最终文本、按轮次结束原因决定的退出码)属于进程级,并由启动器 e2e 负责;runner 不注册任何内容,树内也没有任何可变关系可审计。 |
| [`tests/headless.spec.ts`](tests/headless.spec.ts) | 运行流程、汇总、flush 与退出映射 |
| [`tests/headless.spec.ts`](tests/headless.spec.ts) | 运行流程、汇总、flush、Session 沿用与退出映射 |
| [`tests/json-stream.spec.ts`](tests/json-stream.spec.ts) | 投影顺序、提交点发射、限长与释放 |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 |
### 不变式归属
@@ -121,9 +139,11 @@ runner 不向请求前缀添加任何内容;它只是驱动组合出的配置
- **每次运行一个任务**——任务得到回答后进程即退出;没有交互式后续,因此多步工作请拆成多次运行。
- **通过 `dsh` 启动器运行**——以其他方式启动 headless profile 会在启动时失败,因为只有启动器能请求进程退出。
- **首个 token 前没有心跳**——提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。
- **推理进入 stderr 日志**——重定向与监督进程可能保留显著更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。
- **只打印推理和最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印。
- **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。
- **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留显著更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。
- **默认 stdout 只承载最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印,除非显式启用 `--json`。
- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或运行在本 profile 不组合的 agent preset 下的 Session、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务;本进程中已存活的身份同样会被拒绝,因为 runner 无法对它取得独占的运行区间。
- **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。
<a id="dev-note"></a>
### 开发备注
+7 -3
View File
@@ -1,8 +1,10 @@
# The dsh-headless bundle patch: one-shot task mode directly over dsh-base.
# It mounts no Host, HTTP server, Web runtime, or browser plugin. An ordinary
# provider plugin injects `cmdlineArgs`, parses the task positional
# (`dsh --profile headless "<task>"`) and this app's --help, then the direct
# driver creates an Agent through the core registry and prints its durable result.
# (`dsh --profile headless "<task>"`), the `--session-id` and `--json` options,
# and this app's --help, then the direct driver creates a fresh Agent, or adopts
# the exact Session the flag names, through the core registry and prints its
# durable result.
- id: system-prompt
config:
@@ -23,9 +25,11 @@
- id: headless-startup
name: '@deepseek-ai/dsh-headless/startup'
# Reads its task from the ordinary headlessStartup provider.
# Reads its task and run options from the ordinary headlessStartup provider.
- id: headless-runner
name: '@deepseek-ai/dsh-headless'
inject: [headlessStartup]
config:
task: !!js ctx.headlessStartup.task
sessionId: !!js ctx.headlessStartup.sessionId
json: !!js ctx.headlessStartup.json
+5 -2
View File
@@ -30,6 +30,7 @@
"lib/index.js",
"lib/startup.js",
"cordis.patch.yml",
"lib/json-stream-*.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
@@ -52,7 +53,8 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^"
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
@@ -62,6 +64,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^"
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^"
}
}
+211 -45
View File
@@ -1,9 +1,11 @@
/**
* @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
* creates one Agent through the core registry, drives the task to quiescence,
* streams provider reasoning to stderr, flushes its Session, prints the final
* assistant text to stdout, and exits.
* creates one Agent through the core registry (or adopts the exact Session a
* `--session-id` names), drives the task to quiescence, streams provider
* reasoning to stderr, flushes its Session, prints the final assistant text to
* stdout, and exits. With `--json` it projects the run as newline-delimited
* events instead of the final text.
*
* @module @deepseek-ai/dsh-headless
*/
@@ -19,10 +21,15 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
// Empty type imports carry the loader Context merge for the settlement await
// and the cmdline Context merge for the appExit host value.
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
// Empty type imports carry the loader Context merge for the settlement await,
// the cmdline Context merge for the appExit host value, and the sessionQuery
// Context merge for exact Session adoption.
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-cmdline'
import type {} from '@deepseek-ai/dsh-session-query'
import { internals } from './runner-internals.ts'
import { projectJsonRun, boundJsonLine } from './json-stream.ts'
/** Stable Cordis plugin name. */
export const name = 'headless-runner'
@@ -30,14 +37,20 @@ export const name = 'headless-runner'
/** Core services required before the one-shot turn can start. */
export const inject = ['agentDefaultModel', 'agents', 'sessions']
/** Plugin config: the task resolved from this app's injected provider service. */
/** Plugin config: the task and run options resolved from this app's injected provider service. */
export interface Config {
/** The prompt text for the single run. */
task: string
/** The prompt text for the single run; absent when the task arrives on stdin. */
task?: string
/** Exact Session identity to adopt; absent for a fresh random identity. An id with no stored Session fails. */
sessionId?: string
/** Whether stdout carries the machine-readable event stream instead of final text. */
json?: boolean
}
export const Config: z<Config> = z.object({
task: z.string().required(),
task: z.string(),
sessionId: z.string(),
json: z.boolean(),
})
/** Outcome of one owned run interval. */
@@ -54,12 +67,6 @@ interface HeadlessIo {
exit(code: number): void
}
/** The process streams the runner writes to; tests substitute captures. */
export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
stdout: process.stdout,
stderr: process.stderr,
}
/** Aggregate the last assistant text and turn outcome in one owned interval. */
function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome {
let started = false
@@ -155,19 +162,148 @@ function streamReasoning(
}
}
/** The Session facts that decide whether the runner may drive it directly. */
interface AdoptableHeader {
cwd?: string | undefined
origin?: 'subagent' | undefined
parentSession?: SessionId | undefined
agentPreset?: string | undefined
}
/** Iterate a live Session's durable events in order. */
function* liveEvents(session: Session): Generator<SessionEvent> {
const length = session.seq
for (let seq = 0; seq < length; seq++) {
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
const event = session.eventAt(SessionSeq(seq))
if (event === undefined) {
throw new Error(`headless adoption cannot read seq ${String(seq)} below captured length ${String(length)}`)
}
yield event
}
}
/**
* The preset a Session currently runs under: its creation header advanced by
* the last `agent-preset/selected` event. The header is only a creation fact;
* the presets plugin reconstructs a session's composition from the projection.
*/
function currentPreset(header: AdoptableHeader, events: Iterable<SessionEvent>, sessionId: SessionId): string | undefined {
let preset = header.agentPreset
for (const event of events) {
// Owned by dsh-agent-presets, which this bundle does not compose, so the
// event is read structurally rather than through its module augmentation.
const candidate = event as unknown as { type: string; data?: { agentPreset?: unknown } }
if (candidate.type !== 'agent-preset/selected') continue
const selected = candidate.data?.agentPreset
// A corrupt record must not read as "no preset": that would let the run
// continue under this bundle's composition instead of the recorded one.
if (typeof selected !== 'string' || selected === '') {
throw new Error(`session "${sessionId}" records a malformed agent-preset/selected event and cannot be adopted`)
}
preset = selected
}
return preset
}
/** Reject a Session the one-shot runner must not adopt. */
function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>, sessionId: SessionId): void {
const preset = currentPreset(header, events, sessionId)
if (preset !== undefined) {
// This bundle composes no preset roster, so resuming the session here would
// silently run it under the headless tools and prompts instead of the
// composition its log records.
throw new Error(
`session "${sessionId}" runs under agent preset "${preset}", which the one-shot runner does not compose`,
)
}
if (header.origin === 'subagent' || header.parentSession !== undefined) {
throw new Error(`session "${sessionId}" is a subagent or forked session and cannot be driven directly`)
}
if (header.cwd === undefined) {
throw new Error(`session "${sessionId}" recorded no working directory, so it cannot be adopted`)
}
if (header.cwd !== process.cwd()) {
throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${process.cwd()}"`)
}
}
/**
* Resolve the Agent for one run: adopt the persisted Session with the requested
* id. The identity must already exist, and no Agent may be live under it; a
* first round omits the option instead, so a typo cannot pass as a brand-new
* conversation.
* @param ctx - plugin context carrying the Session query service.
* @param agents - the core Agent registry.
* @param sessionId - exact Session identity to adopt.
* @param agentOptions - provider/model pair for this run.
* @param setup - per-Agent scope setup installing the model selection.
* @returns the resumed Agent.
*/
async function resolveAgent(
ctx: Context,
agents: Context['agents'],
sessionId: SessionId,
agentOptions: { provider: string; model: string },
setup: (agentCtx: Context) => void,
): Promise<Agent> {
// Resuming promises the caller a log a later process can continue. Without a
// durable log the run would succeed, print the id, and still lose the whole
// history at exit, so a miscomposed profile fails loud before the resume.
if (ctx.get('sessionPersistence') === undefined) {
throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process')
}
// A later process holds no live Agent and has to find the id through the
// query service, so every --session-id run requires it.
const query = ctx.get('sessionQuery')
if (query === undefined) {
throw new Error('headless --session-id requires the sessionQuery service; dsh-base provides it')
}
const live = agents.get(sessionId)
if (live !== undefined) {
// A live Agent already has an owner that may still drive it, and `whenIdle`
// is not a single-message signal: folding its next interval into this run
// would mix that owner's events — even its final answer — into the stream.
// The runner cannot claim an exclusive interval over an Agent it did not
// create, so it refuses the identity; the adoptability rules run first so a
// real mismatch is named instead of the generic refusal.
assertAdoptable(live.session.header, liveEvents(live.session), sessionId)
throw new Error(`session "${sessionId}" is live in this process, so the one-shot runner cannot own an exclusive run interval`)
}
try {
using observation = await query.observeSession(sessionId)
assertAdoptable(observation.header, observation.events, sessionId)
const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup })
// The observation is a snapshot: another writer may have appended a preset
// selection before this process took the write lease. Re-check the log
// resume actually attached, now that no other process can append.
assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId)
return agent
} catch (error: unknown) {
if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
// --session-id resumes a conversation that already exists; starting a new
// one is the no-id path, which generates its own identity and reports it in
// the `session` event. Creating the requested id here would turn a typo
// into a brand-new empty history the caller believes it is continuing.
throw new Error(`session "${sessionId}" does not exist; omit --session-id to start a new Session`)
}
}
/** Report an unexpected direct-driver failure and request a failing exit. */
function fail(io: HeadlessIo, error: unknown): void {
io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
function fail(io: HeadlessIo, error: unknown, json: boolean): void {
const message = error instanceof Error ? error.message : String(error)
if (json) io.stdout.write(`${boundJsonLine({ type: 'error', message })}\n`)
io.stderr.write(`dsh: ${message}\n`)
io.exit(1)
}
/**
* Run one task through a freshly created Agent and request process exit.
* Run one task through one Agent and request process exit.
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
* @param task - one-shot task text.
* @param config - task, optional exact Session identity, and output mode.
* @param io - process-facing effects.
*/
async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
async function run(ctx: Context, config: Config, io: HeadlessIo): Promise<void> {
// Loader siblings mount concurrently. Await the complete application before
// creating an Agent so its scoped tools and adapters are not half-composed.
await ctx.get('loader')?.await()
@@ -177,45 +313,75 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
// Early process shutdown can dispose the tree while settlement is pending.
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
// A Cordis overlay sets the row directly and bypasses the CLI trim check, so
// the same public setting must fail here rather than become a blank identity.
if (config.sessionId !== undefined && config.sessionId.trim() === '') {
throw new Error('headless-runner: sessionId must not be blank')
}
const task = config.task === undefined || config.task === '-'
? await internals.readStdin()
: config.task
if (task.trim() === '') {
throw new Error('a task is required, for example: dsh --profile headless "run the tests"')
}
const selection = defaultModel.currentSelection()
const agentOptions = { provider: selection.provider, model: selection.model }
// This bundle composes no preset roster, so the model-facing rows sit in the
// host plane and the agent reads them from the global layer. A deployment
// that DOES configure one has to join it here first
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
const { agent } = await agents.create({
sessionId: brandString<SessionId>(`session-${randomUUID()}`),
meta: { cwd: process.cwd() },
agentOptions: { provider: selection.provider, model: selection.model },
setup: (agentCtx) => {
const selected: ModelSelectionRef = { current: selection, assembled: undefined }
installModelSelection(agentCtx, selected)
},
})
const setup = (agentCtx: Context): void => {
const selected: ModelSelectionRef = { current: selection, assembled: undefined }
installModelSelection(agentCtx, selected)
}
const sessionId = brandString<SessionId>(config.sessionId ?? `session-${randomUUID()}`)
const agent = config.sessionId === undefined
? (await agents.create({
sessionId,
meta: { cwd: process.cwd() },
agentOptions,
setup,
})).agent
: await resolveAgent(ctx, agents, sessionId, agentOptions, setup)
await agent.whenIdle()
if (config.sessionId !== undefined) {
// The resume-time check read a snapshot; an overlay can still append a
// preset selection between it and the interval this run now owns, so
// re-read the log the runner holds before submitting the task.
assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId)
}
const firstSeq = agent.session.seq
const stopReasoning = streamReasoning(ctx, agent, io.stderr)
const projection = config.json === true ? projectJsonRun(ctx, agent, io.stdout) : undefined
const stopReasoning = projection === undefined ? streamReasoning(ctx, agent, io.stderr) : undefined
try {
agent.followup(createUserMessage({
content: [{ type: 'text', text: task }],
source: { kind: 'user' },
}))
await agent.whenIdle()
try {
agent.followup(createUserMessage({
content: [{ type: 'text', text: task }],
source: { kind: 'user' },
}))
await agent.whenIdle()
} finally {
stopReasoning?.()
}
await sessions.flush(agent.session)
const outcome = summarize(agent.session, firstSeq)
if (projection === undefined) io.stdout.write(outcome.text + '\n')
else projection.finish(outcome.text)
if (outcome.reason?.kind === 'error') {
io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
}
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
} finally {
stopReasoning()
projection?.dispose()
}
await sessions.flush(agent.session)
const outcome = summarize(agent.session, firstSeq)
io.stdout.write(outcome.text + '\n')
if (outcome.reason?.kind === 'error') {
io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
}
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
}
/**
* Mount the one-shot direct driver.
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
* @param config - validated task config.
* @param config - validated task and run options.
*/
export function apply(ctx: Context, config: Config): void {
// Read through the global service store, not the property proxy: appExit is
@@ -225,5 +391,5 @@ export function apply(ctx: Context, config: Config): void {
throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
}
const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
void run(ctx, config, io).catch((error: unknown) => { fail(io, error, config.json === true) })
}
+332
View File
@@ -0,0 +1,332 @@
/**
* `--json` run projection: a bounded, ordered event stream derived from one
* Agent's durable Session events. Every projected event is a commit point:
* text and reasoning come from committed `assistant/message` content, never
* from a live attempt that may still be retried or discarded, so the stream
* never carries content the durable log does not contain.
* @module @deepseek-ai/dsh-headless/json-stream
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { lastAssistantStreamChunk } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/** Default per-string and per-key cap applied to every bounded projected payload. */
export const MAX_STRING_BYTES = 8 * 1024
/** Default cap on one projected event's serialized bytes, newline included; the terminal `final` is exempt. */
export const MAX_EVENT_BYTES = 32 * 1024
/** Bytes every newline-delimited writer appends after one serialized event. */
const LINE_TERMINATOR_BYTES = 1
/** The stdout sink a projection writes newline-delimited events to. */
export interface JsonSink {
/** Write one chunk of the event stream. */
write(chunk: string): unknown
}
/** Tunables for {@link projectJsonRun}; every field defaults. */
export interface JsonProjectionOptions {
/** Working directory reported by the opening `session` event. */
cwd?: string
/** Per-string and per-key byte cap; longer values are truncated and flagged. */
maxStringBytes?: number
}
/** The live handle of one `--json` projection. */
export interface JsonProjection {
/** Write the terminal `final` event carrying the run's answer text. */
finish(text: string): void
/** Stop observing the Session. */
dispose(): void
}
/** Mutable truncation state threaded through one payload bound. */
interface BoundState {
truncated: boolean
}
/** Truncate one UTF-8 string to `maxBytes`, dropping a split trailing character. */
function truncateUtf8(text: string, maxBytes: number): string {
const buffer = Buffer.from(text, 'utf8').subarray(0, maxBytes)
const decoded = buffer.toString('utf8')
return decoded.endsWith('\uFFFD') ? decoded.slice(0, -1) : decoded
}
/** Cap one object key, flagging the payload when it was cut. */
function boundKey(key: string, maxBytes: number, state: BoundState): string {
if (Buffer.byteLength(key, 'utf8') <= maxBytes) return key
state.truncated = true
return truncateUtf8(key, maxBytes)
}
/** Maximum container depth one projected payload keeps before the tail is cut. */
const MAX_DEPTH = 64
/** Recursively cap every string in one JSON-serializable value, keys included. */
function boundValue(value: unknown, maxBytes: number, state: BoundState, depth = 0): unknown {
if (typeof value === 'string') {
if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value
state.truncated = true
return truncateUtf8(value, maxBytes)
}
if (Array.isArray(value)) {
// A legal but pathologically deep argument would otherwise recurse until
// the stack overflows, and the isolated listener would silently drop the
// event; cut the tail at a depth no real tool schema reaches.
if (depth >= MAX_DEPTH) {
state.truncated = true
return '[truncated: depth]'
}
return value.map(item => boundValue(item, maxBytes, state, depth + 1))
}
if (value !== null && typeof value === 'object') {
if (depth >= MAX_DEPTH) {
state.truncated = true
return '[truncated: depth]'
}
// A null prototype keeps a literal `__proto__` key as data instead of
// invoking the inherited setter, which would silently drop it. Two keys
// that share a truncated prefix collide last-wins; only a payload naming
// two multi-kilobyte keys can reach that.
const bounded = Object.create(null) as Record<string, unknown>
for (const [key, item] of Object.entries(value)) {
bounded[boundKey(key, maxBytes, state)] = boundValue(item, maxBytes, state, depth + 1)
}
return bounded
}
return value
}
/**
* Bound every string and key in one projected payload, adding `truncated: true`
* when any was cut. {@link boundJsonLine} composes this with the whole-line cap.
* @param event - the event payload to bound.
* @param maxStringBytes - per-string and per-key byte cap.
* @returns a copy with every over-long string and key truncated.
*/
function boundJsonEvent(
event: Record<string, unknown>,
maxStringBytes: number = MAX_STRING_BYTES,
): Record<string, unknown> {
const state: BoundState = { truncated: false }
const bounded = boundValue(event, maxStringBytes, state) as Record<string, unknown>
if (state.truncated) bounded.truncated = true
return bounded
}
/**
* Serialize one projected payload under both limits: every string and key is
* capped at `maxStringBytes`, and the serialized line at `maxEventBytes`. The
* line cap reserves the newline the writer appends, so the complete record
* stays within it. When the line is still too long, scalar fields survive and
* structured fields are dropped; when even those are too long, only `type` and
* `truncated` remain.
* @param event - the event payload to serialize.
* @param maxStringBytes - per-string and per-key byte cap.
* @param maxEventBytes - cap on the serialized line plus its trailing newline.
* @returns the bounded JSON line, without a trailing newline.
*/
export function boundJsonLine(
event: Record<string, unknown>,
maxStringBytes: number = MAX_STRING_BYTES,
maxEventBytes: number = MAX_EVENT_BYTES,
): string {
const limit = maxEventBytes - LINE_TERMINATOR_BYTES
const bounded = boundJsonEvent(event, maxStringBytes)
const line = JSON.stringify(bounded)
if (Buffer.byteLength(line, 'utf8') <= limit) return line
const scalars = Object.create(null) as Record<string, unknown>
for (const [key, value] of Object.entries(bounded)) {
if (value === null || typeof value !== 'object') scalars[key] = value
}
scalars.truncated = true
const short = JSON.stringify(scalars)
if (Buffer.byteLength(short, 'utf8') <= limit) return short
return JSON.stringify({ type: bounded.type, truncated: true })
}
/**
* Parse raw tool-call arguments as the executor does: empty input is `{}`,
* invalid or non-round-trippable JSON (a non-finite number) stays text.
*/
function parseArguments(raw: string): unknown {
if (raw === '') return {}
const seen = { nonFinite: false }
try {
const parsed = JSON.parse(raw, (_key, value: unknown) => {
// `1e400` is valid JSON but parses to Infinity, which JSON.stringify
// reports as null; keep the raw text rather than misdescribe the call.
if (typeof value === 'number' && !Number.isFinite(value)) seen.nonFinite = true
return value
}) as unknown
return seen.nonFinite ? raw : parsed
} catch {
return raw
}
}
/** Join the text blocks of a tool result's model-facing content. */
function resultText(blocks: readonly { type: string; text?: string }[]): string {
return blocks
.filter((block): block is { type: string; text: string } =>
block.type === 'text' && typeof block.text === 'string')
.map(block => block.text)
.join('')
}
/** Provider token accounting for one step, as carried by `assistant/message`. */
type StepUsage = NonNullable<SessionEvent<'assistant/message'>['data']['usage']>
/** Accumulated usage for one step plus whether every attempt reported a sample. */
interface StepUsageState {
usage: StepUsage | undefined
/** False once any attempt in the step omitted a usage sample. */
complete: boolean
}
/** The usage a provider reported in one attempt's stream, if any. */
function streamUsage(stream: SessionEvent<'assistant/message'>['data']['stream']): StepUsage | undefined {
return lastAssistantStreamChunk(stream, 'usage')?.usage
}
/**
* Accumulate one step's usage across its attempts. A retried attempt keeps its
* usage only in its `assistant/attempt` stream, so the committed message alone
* would under-report billed tokens. An attempt that reports no sample makes the
* step total unknowable, so the step omits usage entirely rather than publish a
* partial sum as if it were complete; an optional bucket is summed only when
* every contribution reports it.
* @param state - usage accumulated so far and whether it is still complete.
* @param next - usage reported by the next attempt.
* @returns the updated state.
*/
function addUsage(state: StepUsageState, next: StepUsage | undefined): StepUsageState {
if (next === undefined) return { usage: state.usage, complete: false }
if (state.usage === undefined) return { usage: next, complete: state.complete }
const total = state.usage
const sum = (a: number | undefined, b: number | undefined): number | undefined =>
a === undefined || b === undefined ? undefined : a + b
const totalTokens = sum(total.totalTokens, next.totalTokens)
const cacheReadTokens = sum(total.cacheReadTokens, next.cacheReadTokens)
const cacheWriteTokens = sum(total.cacheWriteTokens, next.cacheWriteTokens)
const reasoningTokens = sum(total.reasoningTokens, next.reasoningTokens)
return {
usage: {
inputTokens: total.inputTokens + next.inputTokens,
outputTokens: total.outputTokens + next.outputTokens,
...totalTokens === undefined ? {} : { totalTokens },
...cacheReadTokens === undefined ? {} : { cacheReadTokens },
...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
...reasoningTokens === undefined ? {} : { reasoningTokens },
},
complete: state.complete,
}
}
/**
* Project one Agent's run as newline-delimited JSON on `sink`.
*
* The opening `session` event is written before the subscription starts, so a
* caller must invoke this before submitting the task. Text and reasoning are
* emitted only when the step's `assistant/message` commits them, and the
* terminal `final` event carries the same lossless answer the default mode
* prints (it is deliberately not bounded).
* @param ctx - plugin context carrying the live Session feed.
* @param agent - the exact Agent whose events belong to this invocation.
* @param sink - stdout sink receiving one JSON object per line.
* @param options - projection tunables.
* @returns the projection handle that finishes or disposes the stream.
*/
export function projectJsonRun(
ctx: Context,
agent: Agent,
sink: JsonSink,
options: JsonProjectionOptions = {},
): JsonProjection {
const maxStringBytes = options.maxStringBytes ?? MAX_STRING_BYTES
let disposed = false
let stepUsage: StepUsageState = { usage: undefined, complete: true }
const write = (event: Record<string, unknown>): void => {
sink.write(`${boundJsonLine(event, maxStringBytes)}\n`)
}
const onSessionEvent = (session: unknown, event: SessionEvent): void => {
if (session !== agent.session) return
switch (event.type) {
case 'turn/start':
write({ type: 'status', phase: 'turn_start', turn: event.data.turn })
return
case 'step/start':
write({ type: 'status', phase: 'step_start', turn: event.data.turn, step: event.data.step })
return
case 'assistant/attempt':
// A retried attempt has no committed message; its billed tokens live
// only in the stream, so accumulate them into the step's total.
stepUsage = addUsage(stepUsage, streamUsage(event.data.stream))
return
case 'assistant/message':
stepUsage = addUsage(stepUsage, event.data.usage ?? streamUsage(event.data.stream))
for (const block of event.data.message.content) {
if (block.type === 'reasoning') write({ type: 'thinking', text: block.text })
else if (block.type === 'text') write({ type: 'text', text: block.text })
}
return
case 'step/end': {
const { usage, complete } = stepUsage
stepUsage = { usage: undefined, complete: true }
write({
type: 'status', phase: 'step_end', turn: event.data.turn, step: event.data.step,
// A partial sum would read as an exact total, so omit it entirely
// when any attempt in the step failed to report usage.
...complete && usage !== undefined ? { usage } : {},
})
return
}
case 'turn/end':
write({ type: 'status', phase: 'turn_end', turn: event.data.turn, reason: event.data.reason })
return
case 'tool/call':
write({
type: 'tool_call',
callId: event.data.callId,
tool: event.data.name,
input: parseArguments(event.data.arguments),
})
return
case 'tool/result': {
// Compaction replaces older results in the surface; those are history,
// not this run's output, and would otherwise duplicate a callId.
if (event.surfaceOp !== 'append') return
const block = event.data.message.content[0]
write({
type: 'tool_result',
callId: block.toolCallId,
status: block.isError === true ? 'error' : 'completed',
result: resultText(block.content),
})
return
}
default:
return
}
}
write({ type: 'session', sessionId: agent.id, cwd: options.cwd ?? process.cwd() })
const stopSession = ctx.on('session/event', onSessionEvent)
return {
finish(text: string): void {
// The answer is the lossless terminal contract, so it is not truncated.
if (disposed) return
sink.write(`${JSON.stringify({ type: 'final', text })}\n`)
},
dispose(): void {
disposed = true
stopSession()
},
}
}
@@ -0,0 +1,21 @@
/**
* Process streams the runner reads and writes, kept out of the package entry so
* substituting them in tests adds no public package API. The shape matches the
* runner's own IO carrier structurally.
* @module @deepseek-ai/dsh-headless/runner-internals
*/
/** The process streams the runner reads and writes; tests substitute captures. */
export const internals: {
stdout: { write(chunk: string): unknown }
stderr: { write(chunk: string): unknown }
readStdin: () => Promise<string>
} = {
stdout: process.stdout,
stderr: process.stderr,
readStdin: async () => {
const chunks: Buffer[] = []
for await (const chunk of process.stdin as AsyncIterable<Buffer>) chunks.push(chunk)
return Buffer.concat(chunks).toString('utf8')
},
}
@@ -0,0 +1,14 @@
/**
* Process facts the startup provider reads, kept out of the `./startup` entry
* so substituting them in tests adds no public package API.
* @module @deepseek-ai/dsh-headless/startup-internals
*/
/** Process facts the provider reads; tests substitute them. */
export const internals: {
stdinIsTty: () => boolean
stdout: { write(chunk: string): unknown }
} = {
stdinIsTty: () => process.stdin.isTTY,
stdout: process.stdout,
}
+79 -15
View File
@@ -1,13 +1,16 @@
/**
* The one-shot app's command-line provider: it parses the task positional and
* `--help`, then publishes {@link HEADLESS_STARTUP_SERVICE}. The runner is an
* ordinary consumer whose lazy config waits for that service.
* The one-shot app's command-line provider: it parses the task positional,
* `--session-id`, `--json`, and `--help`, then publishes
* {@link HEADLESS_STARTUP_SERVICE}. The runner is an ordinary consumer whose
* lazy config waits for that service.
* @module @deepseek-ai/dsh-headless/startup
*/
import { Command } from 'commander'
import { Command, CommanderError } from 'commander'
import type { Context } from '@deepseek-ai/cordis'
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
import { boundJsonLine } from './json-stream.ts'
import { internals } from './startup-internals.ts'
/** Stable Cordis plugin name. */
export const name = 'headless-startup'
@@ -20,38 +23,99 @@ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup'
/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */
export interface HeadlessStartupValues {
/** The task text this invocation asked for. */
task: string
/** The task text this invocation asked for; absent when the runner reads stdin. */
task: string | undefined
/** Exact Session identity to adopt; absent for a fresh random identity. */
sessionId: string | undefined
/** Whether stdout carries the machine-readable event stream instead of final text. */
json: boolean
}
/**
* This app's command: the task positional, its description, and its help text.
* This app's command: the task positional, its options, and its help text.
* @returns a fresh program, so one process can parse more than once (tests).
*/
function headlessCommand(): Command {
return new Command()
.name('dsh --profile headless')
.description('Answer one task, stream reasoning to stderr, print the final assistant message, and exit.')
.description('Answer one task and exit; the answer goes to stdout and diagnostics to stderr.')
.helpOption('-h, --help', 'show this help')
.argument('[task...]', 'the task text; multiple words are joined by spaces')
.option('--json', 'write newline-delimited run events to stdout instead of the final message')
.option('--session-id <id>', 'adopt the persisted Session with this id; an unknown id is an error')
.argument('[task...]', 'the task text; multiple words are joined by spaces, and `-` reads stdin')
.addHelpText('after', `
Examples:
dsh --profile headless "run the tests" answer one task and exit
dsh --profile headless "run the tests" answer one task and exit
echo "run the tests" | dsh --profile headless read the task from stdin
dsh --profile headless --json "run the tests" emit machine-readable run events
dsh --profile headless --session-id session-… "continue" resume an existing Session
`)
}
/**
* Whether the raw invocation asks for the machine-readable stream. The scan
* stops at `--` and skips a `--session-id` value, so a literal `--json` used as
* an option value or a positional never installs the JSON error override.
* @param argv - the invocation's raw arguments.
* @returns whether `--json` is a real flag of this invocation.
*/
function jsonRequested(argv: readonly string[]): boolean {
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--') return false
if (argument === '--json') return true
if (argument === '--session-id') index += 1
}
return false
}
/**
* Parse and provide the one-shot task as an ordinary Cordis service. The
* command's action publishes the task; a missing or whitespace-only task is a
* usage error, so on rejection (and on `--help`) nothing is provided.
* command's action publishes the task; a missing task on an interactive stdin
* is a usage error, so on rejection (and on `--help`) nothing is provided.
* @param ctx - plugin context carrying the command line.
*/
export function apply(ctx: Context): void {
const program = headlessCommand()
// The raw snapshot decides the JSON contract: Commander rejects a grammar
// error (an unknown option, a missing option value) before the action runs,
// and such a rejection still owes a --json caller the error event.
if (jsonRequested(ctx.get('cmdlineArgs')?.get() ?? [])) {
program.error = (message: string, errorOptions?: Parameters<Command['error']>[1]): never => {
// The event message matches the runner's runtime errors, which carry no
// commander `error: ` prefix.
const payload = boundJsonLine({ type: 'error', message: message.replace(/^error: /, '') })
internals.stdout.write(`${payload}\n`)
// The JSON contract keeps stderr to `dsh:` diagnostics, so commander's
// own print of this message must not run; throwing the same control-flow
// error still leaves through the launcher's exit path.
throw new CommanderError(1, errorOptions?.code ?? 'commander.error', message)
}
}
program.action(() => {
const task = program.args.join(' ')
if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues)
if (program.args.length > 1 && program.args.includes('-')) {
program.error('error: `-` must be the only task argument')
}
const joined = program.args.join(' ')
if (program.args.length > 0 && joined.trim() === '') {
program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
}
const task = program.args.length === 0 ? undefined : joined
if (task === undefined && internals.stdinIsTty()) {
program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
}
const options = program.opts<{ json?: boolean; sessionId?: string }>()
// A SessionId is opaque, so whitespace is part of the identity: validate
// emptiness on the trimmed value but hand the runner the exact string.
const sessionId = options.sessionId
if (sessionId !== undefined && sessionId.trim() === '') {
program.error('error: --session-id requires a non-empty session id')
}
ctx.provide(HEADLESS_STARTUP_SERVICE, {
task,
sessionId,
json: options.json === true,
} satisfies HeadlessStartupValues)
})
parseCmdline(ctx, program)
}
+595 -36
View File
@@ -1,16 +1,26 @@
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
/** Direct one-shot Agent driving, exact Session adoption, machine-readable projection, and exit mapping. */
import { Readable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, AssistantStreamFrame, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import type {
Agent,
AgentHandle,
AssistantStreamFrame,
CreateAgentOptions,
ResumeAgentOptions,
} from '@deepseek-ai/dsh-agent'
import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
import { LlmAttemptId, createAssistantMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import { apply, Config, internals } from '../src/index.ts'
import { apply, Config } from '../src/index.ts'
import { internals } from '../src/runner-internals.ts'
const originalInternals = { ...internals }
afterEach(() => { Object.assign(internals, originalInternals) })
@@ -20,6 +30,33 @@ interface Script {
afterPrompt(session: Session, message: UserMessage, agent: Agent): Promise<void> | void
}
/** Observation stub returned by the `--session-id` query path. */
interface ObservationStub {
header: { cwd?: string; origin?: string; parentSession?: string; agentPreset?: string }
events: readonly { type: string; data: unknown }[]
[Symbol.dispose](): void
}
/** Runner invocation options layered over the scripted Agent factory. */
interface BenchOptions {
task?: string
useStdin?: boolean
readStdin?: () => Promise<string>
sessionId?: string
json?: boolean
observe?: () => Promise<ObservationStub>
/** Leave the query service unmounted to exercise the fail-loud path. */
omitSessionQuery?: boolean
/** Leave the persistence service unmounted to exercise the fail-loud path. */
omitPersistence?: boolean
/** Register a live Agent under `sessionId` before the runner starts. */
prelive?: boolean
/** Header facts for that pre-registered live Agent. */
preliveMeta?: { cwd?: string; origin?: 'subagent'; agentPreset?: string }
/** Run when the runner awaits idle, e.g. to append to the attached log. */
onWhenIdle?: (agent: Agent) => void
}
const frameStates = new WeakMap<Agent, { attemptId: ReturnType<typeof LlmAttemptId>; revision: number; index: number }>()
function startFrames(agent: Agent, turn = 1, step = 1): void {
@@ -73,8 +110,14 @@ function appendTurn(
})
}
/** Append the preset-selection event owned by dsh-agent-presets. */
function selectPreset(session: Session, agentPreset: string): void {
const target = session as unknown as { append(type: string, data: unknown): void }
target.append('agent-preset/selected', { agentPreset })
}
/** Mount the real registries around a small scripted Agent factory. */
async function bench(script: Script): Promise<{
async function bench(script: Script, options: BenchOptions = {}): Promise<{
ctx: Context
output(): { out: string; err: string; order: string[] }
run(): Promise<{ code: number; out: string; err: string; order: string[] }>
@@ -83,42 +126,67 @@ async function bench(script: Script): Promise<{
let out = ''
let err = ''
const order: string[] = []
const mount = async (
ownerCtx: Context,
session: Session,
createOptions: CreateAgentOptions | ResumeAgentOptions,
): Promise<Agent> => {
const inbox = createInboxStub()
let idle = Promise.resolve()
const agent: Agent = {
id: session.id,
options: createOptions.agentOptions ?? {},
session,
inbox,
status: 'idle',
ctx: ownerCtx,
cancel: () => {},
runMaintenance: () => Promise.reject(new Error('not used')),
send: () => {},
followup: (message: UserMessage) => {
agent.inbox.append('next-turn', message)
idle = Promise.resolve().then(() => script.afterPrompt(session, message, agent))
},
steer: () => {},
inject: () => {},
whenIdle: () => {
options.onWhenIdle?.(agent)
return idle
},
}
await createOptions.setup?.(ownerCtx, agent)
ctx.agents.register(agent)
return agent
}
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' })
ctx.agents.setFactory({
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const session = ctx.sessions.create(options.sessionId, {
...options.meta === undefined ? {} : { meta: options.meta },
async createAgent(ownerCtx: Context, createOptions: CreateAgentOptions): Promise<AgentHandle> {
const session = ctx.sessions.create(createOptions.sessionId, {
...createOptions.meta === undefined ? {} : { meta: createOptions.meta },
})
const inbox = createInboxStub()
let idle = Promise.resolve()
const agent: Agent = {
id: session.id,
options: options.agentOptions ?? {},
session,
inbox,
status: 'idle',
ctx: ownerCtx,
cancel: () => {},
runMaintenance: () => Promise.reject(new Error('not used')),
send: () => {},
followup: (message: UserMessage) => {
agent.inbox.append('next-turn', message)
idle = Promise.resolve().then(() => script.afterPrompt(session, message, agent))
},
steer: () => {},
inject: () => {},
whenIdle: () => idle,
}
await options.setup?.(ownerCtx, agent)
script.before?.(session)
ctx.agents.register(agent)
const agent = await mount(ownerCtx, session, createOptions)
return { agent, dispose: () => Promise.resolve() }
},
async resume(ownerCtx: Context, resumeOptions: ResumeAgentOptions): Promise<AgentHandle> {
const session = ctx.sessions.get(resumeOptions.resumeSessionId)
if (session === undefined) throw new Error(`no attached Session ${resumeOptions.resumeSessionId}`)
const agent = await mount(ownerCtx, session, resumeOptions)
return { agent, dispose: () => Promise.resolve() }
},
resume: () => Promise.reject(new Error('not used')),
})
if (options.omitSessionQuery !== true && (options.sessionId !== undefined || options.observe !== undefined)) {
const observe = options.observe ?? (() => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')))
ctx.provide('sessionQuery', { observeSession: () => observe() } as never)
}
if (options.omitPersistence !== true) {
ctx.provide('sessionPersistence', {} as never)
}
return {
ctx,
output: () => ({ out, err, order: [...order] }),
@@ -126,10 +194,21 @@ async function bench(script: Script): Promise<{
ctx.on('session/flush', () => { order.push('flush') })
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
if (options.readStdin !== undefined) internals.readStdin = options.readStdin
const exited = new Promise<number>((resolve) => {
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
})
apply(ctx, { task: 'do the thing' })
if (options.prelive === true || options.preliveMeta !== undefined) {
await ctx.agents.create({
sessionId: brandString<SessionId>(options.sessionId ?? 'session-exact'),
meta: { cwd: process.cwd(), ...options.preliveMeta },
})
}
apply(ctx, {
...options.useStdin === true ? {} : { task: options.task ?? 'do the thing' },
...options.sessionId === undefined ? {} : { sessionId: options.sessionId },
...options.json === undefined ? {} : { json: options.json },
})
return { code: await exited, out, err, order }
},
}
@@ -375,6 +454,485 @@ describe('headless runner', () => {
await test.ctx.fiber.dispose()
})
it('reads the task from stdin when the invocation omits one', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'stdin answer', true) },
}, {
useStdin: true,
readStdin: () => Promise.resolve('task from stdin'),
})
expect(await test.run()).toMatchObject({ code: 0, out: 'stdin answer\n', err: '' })
await test.ctx.fiber.dispose()
})
it('rejects an empty stdin task', async () => {
const test = await bench({ afterPrompt: () => {} }, {
useStdin: true,
readStdin: () => Promise.resolve(' \n'),
})
expect(await test.run()).toMatchObject({
code: 1,
err: 'dsh: a task is required, for example: dsh --profile headless "run the tests"\n',
})
await test.ctx.fiber.dispose()
})
it('reads the default process stdin when no override is installed', async () => {
const original = Object.getOwnPropertyDescriptor(process, 'stdin')
Object.defineProperty(process, 'stdin', {
value: Readable.from([Buffer.from('piped'), Buffer.from(' task')]),
configurable: true,
})
try {
await expect(originalInternals.readStdin()).resolves.toBe('piped task')
} finally {
if (original !== undefined) Object.defineProperty(process, 'stdin', original)
}
})
it('treats a bare dash positional as the stdin marker', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'dash answer', true) },
}, {
task: '-',
readStdin: () => Promise.resolve('piped dash task'),
})
expect(await test.run()).toMatchObject({ code: 0, out: 'dash answer\n', err: '' })
await test.ctx.fiber.dispose()
})
it('rejects --session-id when the query reports the id missing', async () => {
const seen: string[] = []
const test = await bench({
afterPrompt(session, message) {
seen.push(session.id)
appendTurn(session, 1, message, 'created', true)
},
}, {
sessionId: 'session-exact',
observe: () => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('session "session-exact" does not exist; omit --session-id to start a new Session')
expect(result.out).toBe('')
expect(seen).toEqual([])
await test.ctx.fiber.dispose()
})
it('rejects --session-id when persistence is not mounted', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'created', true) },
}, {
sessionId: 'session-exact',
observe: () => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')),
omitPersistence: true,
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('requires the sessionPersistence service')
expect(result.out).toBe('')
await test.ctx.fiber.dispose()
})
it('rejects adopting a live Session when persistence is not mounted', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) },
}, {
sessionId: 'session-exact',
prelive: true,
omitPersistence: true,
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('requires the sessionPersistence service')
expect(result.out).toBe('')
await test.ctx.fiber.dispose()
})
it('resumes the persisted Session when the query finds it', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'resumed answer', true) },
}, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd(), origin: 'user' },
events: [],
[Symbol.dispose]() {},
}),
})
const session = test.ctx.sessions.create(brandString<SessionId>('session-exact'), { meta: { cwd: process.cwd() } })
const history = {
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
} as UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
const before = session.seq
expect(await test.run()).toMatchObject({ code: 0, out: 'resumed answer\n', err: '' })
expect(session.seq).toBeGreaterThan(before)
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session recorded in another working directory', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: '/somewhere/else', origin: 'user' },
events: [],
[Symbol.dispose]() {},
}),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('was recorded in "/somewhere/else"')
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session created under an agent preset', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd(), agentPreset: 'minimal' },
events: [],
[Symbol.dispose]() {},
}),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('runs under agent preset "minimal"')
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session that switched to an agent preset after creation', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd() },
events: [{ type: 'agent-preset/selected', data: { agentPreset: 'minimal' } }],
[Symbol.dispose]() {},
}),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('runs under agent preset "minimal"')
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session whose preset record names no preset', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd() },
events: [{ type: 'agent-preset/selected', data: {} }],
[Symbol.dispose]() {},
}),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('malformed agent-preset/selected event')
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session that recorded no working directory', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: {},
events: [],
[Symbol.dispose]() {},
}),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('recorded no working directory')
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session owned by a subagent', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd(), origin: 'subagent' },
events: [],
[Symbol.dispose]() {},
}),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('is a subagent or forked session')
await test.ctx.fiber.dispose()
})
it('requires the Session query service for an exact Session identity', async () => {
const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact', omitSessionQuery: true })
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('requires the sessionQuery service')
await test.ctx.fiber.dispose()
})
it('requires the Session query service even when a live Agent holds the identity', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) },
}, {
sessionId: 'session-exact',
prelive: true,
omitSessionQuery: true,
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('requires the sessionQuery service')
expect(result.out).toBe('')
await test.ctx.fiber.dispose()
})
it('rejects a whitespace-only session identity from configuration', async () => {
const test = await bench({ afterPrompt: () => {} }, { sessionId: ' ' })
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('sessionId must not be blank')
expect(result.out).toBe('')
await test.ctx.fiber.dispose()
})
it('refuses a live Agent identity it cannot own exclusively', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'live answer', true) },
}, {
sessionId: 'session-exact',
prelive: true,
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('is live in this process, so the one-shot runner cannot own an exclusive run interval')
expect(result.out).toBe('')
await test.ctx.fiber.dispose()
})
it('rejects a live Agent recorded in another working directory', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
preliveMeta: { cwd: '/somewhere/else' },
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('was recorded in "/somewhere/else"')
await test.ctx.fiber.dispose()
})
it('rejects a live Agent owned by a subagent', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
preliveMeta: { origin: 'subagent' },
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('is a subagent or forked session')
await test.ctx.fiber.dispose()
})
it('rejects a live Agent created under an agent preset', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
preliveMeta: { agentPreset: 'minimal' },
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('runs under agent preset "minimal"')
await test.ctx.fiber.dispose()
})
it('rejects a live Agent that switched to an agent preset while blank', async () => {
const test = await bench({
before(session) {
const history = {
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
} as UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
selectPreset(session, 'minimal')
},
afterPrompt: () => {},
}, {
sessionId: 'session-exact',
prelive: true,
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('runs under agent preset "minimal"')
await test.ctx.fiber.dispose()
})
it('rejects a preset appended after the observation snapshot was taken', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd() },
events: [],
[Symbol.dispose]() {},
}),
})
const session = test.ctx.sessions.create(brandString<SessionId>('session-exact'), { meta: { cwd: process.cwd() } })
selectPreset(session, 'minimal')
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('runs under agent preset "minimal"')
await test.ctx.fiber.dispose()
})
it('rejects a preset an overlay appends while the runner awaits idle', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd() },
events: [],
[Symbol.dispose]() {},
}),
onWhenIdle: (agent) => { selectPreset(agent.session, 'minimal') },
})
test.ctx.sessions.create(brandString<SessionId>('session-exact'), { meta: { cwd: process.cwd() } })
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('runs under agent preset "minimal"')
expect(result.out).toBe('')
await test.ctx.fiber.dispose()
})
it('fails when a live event below the captured Session length cannot be read', async () => {
let capturedLength = 0
const test = await bench({
before(session) {
const history = {
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
} as UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
capturedLength = session.seq
Object.defineProperty(session, 'eventAt', { value: () => undefined })
},
afterPrompt: () => {},
}, {
sessionId: 'session-exact',
prelive: true,
})
const result = await test.run()
expect(capturedLength).toBeGreaterThan(0)
expect(result.code).toBe(1)
expect(result.err).toContain(`headless adoption cannot read seq 0 below captured length ${String(capturedLength)}`)
await test.ctx.fiber.dispose()
})
it('bounds the error event message in --json mode', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
json: true,
observe: () => Promise.reject(new SessionQueryError('x'.repeat(9 * 1024), 'SESSION_QUERY_CORRUPT_SESSION')),
})
const result = await test.run()
const event = JSON.parse(result.out.trim()) as { message: string; truncated?: boolean }
expect(event.truncated).toBe(true)
expect(event.message.length).toBe(8 * 1024)
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session linked to a parent', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd(), origin: 'user', parentSession: 'parent-1' },
events: [],
[Symbol.dispose]() {},
}),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('is a subagent or forked session')
await test.ctx.fiber.dispose()
})
it('propagates a Session query failure that is not a missing log', async () => {
const test = await bench({ afterPrompt: () => {} }, {
sessionId: 'session-exact',
observe: () => Promise.reject(new SessionQueryError('log is corrupt', 'SESSION_QUERY_CORRUPT_SESSION')),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toBe('dsh: log is corrupt\n')
await test.ctx.fiber.dispose()
})
it('projects the run as ordered newline-delimited events in --json mode', async () => {
const test = await bench({
afterPrompt(session, message, agent) {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
startFrames(agent)
// A live attempt that never commits must not reach the projection.
emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'discarded attempt' })
emitChunk(agent, { type: 'text-delta', index: 0, text: 'discarded answer' })
session.append('assistant/message', {
stream: [],
turn: 1,
step: 1,
usage: { inputTokens: 3, outputTokens: 4 },
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: 'thinking hard' },
{ type: 'text', text: 'answer' },
{ type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{"command":"ls"}' },
],
source: { provider: 'test-provider', model: 'test-model' },
}),
}, { surfaceOp: 'append' })
session.append('tool/call', {
turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{"command":"ls"}',
})
session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: ToolCallId('call-1'),
content: [{ type: 'text', text: 'a.txt' }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
},
}, { json: true })
const result = await test.run()
const events = result.out.trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(events.map(event => event.type)).toEqual([
'session', 'status', 'status', 'thinking', 'text',
'tool_call', 'tool_result', 'status', 'status', 'final',
])
expect(events[0]).toMatchObject({ type: 'session', cwd: process.cwd() })
expect(typeof events[0]?.sessionId).toBe('string')
expect(events[1]).toMatchObject({ type: 'status', phase: 'turn_start', turn: 1 })
expect(events[3]).toMatchObject({ type: 'thinking', text: 'thinking hard' })
expect(events[4]).toMatchObject({ type: 'text', text: 'answer' })
expect(result.out).not.toContain('discarded')
expect(events[5]).toMatchObject({ type: 'tool_call', callId: 'call-1', tool: 'bash', input: { command: 'ls' } })
expect(events[6]).toMatchObject({ type: 'tool_result', callId: 'call-1', status: 'completed', result: 'a.txt' })
expect(events[7]).toMatchObject({ type: 'status', phase: 'step_end', usage: { inputTokens: 3, outputTokens: 4 } })
expect(events[8]).toMatchObject({ type: 'status', phase: 'turn_end', reason: { kind: 'completed' } })
expect(events[9]).toMatchObject({ type: 'final', text: 'answer' })
expect(result.err).toBe('')
expect(result.code).toBe(0)
await test.ctx.fiber.dispose()
})
it('reports a direct failure as an error event in --json mode', async () => {
const test = await bench({ afterPrompt: () => {} }, {
useStdin: true,
readStdin: () => Promise.resolve(''),
json: true,
})
const result = await test.run()
expect(result.code).toBe(1)
expect(JSON.parse(result.out.trim())).toMatchObject({ type: 'error' })
expect(result.err).toContain('a task is required')
await test.ctx.fiber.dispose()
})
it('reports a direct Agent creation failure', async () => {
const ctx = new Context()
let err = ''
@@ -442,8 +1000,9 @@ describe('headless runner', () => {
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
})
it('validates config: the task is required', () => {
expect(() => new Config({} as never)).toThrow()
expect(new Config({ task: 'x' })).toEqual({ task: 'x' })
it('validates config: the task and run options are optional', () => {
expect(new Config({})).toEqual({})
expect(new Config({ task: 'x', sessionId: 'session-x', json: true }))
.toEqual({ task: 'x', sessionId: 'session-x', json: true })
})
})
@@ -0,0 +1,421 @@
/** The `--json` run projection: commit-point emission, ordering, bounding, and disposal. */
import { describe, expect, it } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { boundJsonLine, MAX_STRING_BYTES, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts'
interface ProjectionHarness {
readonly lines: string[]
readonly projection: ReturnType<typeof projectJsonRun>
readonly agent: Agent
readonly session: Session
readonly parsed: () => Record<string, unknown>[]
emitSession(event: SessionEvent): void
emitRawSession(session: unknown, event: SessionEvent): void
}
/** One committed assistant message carrying the given content blocks. */
function assistantMessage(content: unknown[], usage?: unknown): SessionEvent {
return {
type: 'assistant/message',
data: {
stream: [],
turn: 1,
step: 1,
...usage === undefined ? {} : { usage },
message: {
role: 'assistant',
content,
source: { kind: 'model', provider: 'p', model: 'm' },
},
},
} as unknown as SessionEvent
}
/** One discarded attempt whose stream reports the given usage sample. */
function attemptWithUsage(usage: unknown): SessionEvent {
return {
type: 'assistant/attempt',
data: { turn: 1, step: 1, stream: [{ type: 'chunk', chunk: { type: 'usage', usage } }] },
} as unknown as SessionEvent
}
/** One step boundary event closing the accumulated usage window. */
function stepEnd(): SessionEvent {
return { type: 'step/end', data: { turn: 1, step: 1 } } as unknown as SessionEvent
}
/** One tool result event with the given surface placement. */
function toolResult(
callId: string,
content: unknown[],
surfaceOp: 'append' | { op: 'replace'; startSeq: number; endSeq: number } = 'append',
): SessionEvent {
return {
type: 'tool/result',
surfaceOp,
data: {
turn: 1,
step: 1,
message: { content: [{ type: 'tool-result', toolCallId: callId, content, isError: false }] },
},
} as unknown as SessionEvent
}
/** Drive the projector through a minimal Context and Agent double. */
function harness(
options: JsonProjectionOptions = {},
agentId = 'session-1',
cwd: string | null = '/',
): ProjectionHarness {
const lines: string[] = []
const sessionListeners = new Set<(session: unknown, event: SessionEvent) => void>()
const ctx = {
on(name: string, handler: unknown) {
if (name === 'session/event') sessionListeners.add(handler as never)
return () => { sessionListeners.delete(handler as never) }
},
} as unknown as Context
const session = {} as Session
const agent = { id: agentId, session } as unknown as Agent
const projection = projectJsonRun(ctx, agent, {
write: (chunk: string) => { lines.push(chunk); return true },
}, { ...options, ...cwd === null ? {} : { cwd } })
return {
lines,
projection,
agent,
session,
parsed: () => lines.map(line => JSON.parse(line) as Record<string, unknown>),
emitSession: (event) => { for (const listener of sessionListeners) listener(session, event) },
emitRawSession: (rawSession, event) => { for (const listener of sessionListeners) listener(rawSession, event) },
}
}
describe('--json projection', () => {
it('opens with the session event before any observed event', () => {
const test = harness()
expect(test.parsed()).toEqual([{ type: 'session', sessionId: 'session-1', cwd: '/' }])
})
it('defaults the reported cwd and the per-string cap', () => {
const test = harness({}, 'session-1', null)
expect(test.parsed()[0]).toEqual({ type: 'session', sessionId: 'session-1', cwd: process.cwd() })
test.emitSession(assistantMessage([{ type: 'text', text: 'x'.repeat(9000) }]))
expect(test.parsed()[1]?.truncated).toBe(true)
expect((test.parsed()[1]?.text as string).length).toBe(8 * 1024)
})
it('reports turn and step boundaries with the turn-end reason', () => {
const test = harness()
test.emitSession({ type: 'turn/start', data: { turn: 1 } } as unknown as SessionEvent)
test.emitSession({ type: 'step/start', data: { turn: 1, step: 1 } } as unknown as SessionEvent)
test.emitSession({
type: 'turn/end',
data: { turn: 1, reason: { kind: 'completed' } },
} as unknown as SessionEvent)
expect(test.parsed().slice(1)).toEqual([
{ type: 'status', phase: 'turn_start', turn: 1 },
{ type: 'status', phase: 'step_start', turn: 1, step: 1 },
{ type: 'status', phase: 'turn_end', turn: 1, reason: { kind: 'completed' } },
])
})
it('projects committed reasoning and text in content order', () => {
const test = harness()
test.emitSession(assistantMessage([
{ type: 'reasoning', text: 'think' },
{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' },
{ type: 'text', text: 'answer' },
]))
expect(test.parsed().slice(1)).toEqual([
{ type: 'thinking', text: 'think' },
{ type: 'text', text: 'answer' },
])
})
it('ignores a discarded attempt so retried content never reaches the stream', () => {
const test = harness()
test.emitSession({
type: 'assistant/attempt',
data: { turn: 1, step: 1, stream: [] },
} as unknown as SessionEvent)
test.emitSession({ type: 'session/title', data: { title: 'ignored' } } as unknown as SessionEvent)
expect(test.parsed().map(event => event.type)).toEqual(['session'])
})
it('attaches step usage to step_end and omits it when absent', () => {
const test = harness()
test.emitSession({ type: 'step/end', data: { turn: 1, step: 1 } } as unknown as SessionEvent)
test.emitSession(assistantMessage([{ type: 'text', text: 'x' }], { inputTokens: 3, outputTokens: 4 }))
test.emitSession({ type: 'step/end', data: { turn: 1, step: 2 } } as unknown as SessionEvent)
const events = test.parsed()
expect(events[1]).toEqual({ type: 'status', phase: 'step_end', turn: 1, step: 1 })
expect(events[3]).toEqual({
type: 'status', phase: 'step_end', turn: 1, step: 2,
usage: { inputTokens: 3, outputTokens: 4 },
})
})
it('reports tool results as completed or errored and keeps the final event last', () => {
const test = harness()
test.emitSession(toolResult('c1', [{ type: 'text', text: 'a.txt' }]))
test.emitSession({
type: 'tool/result',
surfaceOp: 'append',
data: {
turn: 1,
step: 1,
message: {
content: [{
type: 'tool-result',
toolCallId: 'c2',
content: [{ type: 'image' }, { type: 'text' }, { type: 'text', text: 'boom' }],
isError: true,
}],
},
},
} as unknown as SessionEvent)
test.projection.finish('done')
const events = test.parsed()
expect(events[1]).toEqual({ type: 'tool_result', callId: 'c1', status: 'completed', result: 'a.txt' })
expect(events[2]).toEqual({ type: 'tool_result', callId: 'c2', status: 'error', result: 'boom' })
expect(events.at(-1)).toEqual({ type: 'final', text: 'done' })
})
it('skips a compaction replacement of an older tool result', () => {
const test = harness()
test.emitSession(toolResult('old', [{ type: 'text', text: 'history' }], { op: 'replace', startSeq: 1, endSeq: 2 }))
expect(test.parsed().map(event => event.type)).toEqual(['session'])
})
it('keeps non-JSON tool arguments raw and bounds nested values', () => {
const test = harness({ maxStringBytes: 11 }, 's1')
test.emitSession({
type: 'tool/call',
data: { turn: 1, step: 1, callId: 'raw', name: 'bash', arguments: 'not json' },
} as unknown as SessionEvent)
test.emitSession({
type: 'tool/call',
data: {
turn: 1,
step: 1,
callId: 'nested',
name: 'bash',
arguments: JSON.stringify({ items: ['éééééé', null, true], n: 1 }),
},
} as unknown as SessionEvent)
const events = test.parsed()
expect(events[1]).toEqual({ type: 'tool_call', callId: 'raw', tool: 'bash', input: 'not json' })
expect(events[2]).toEqual({
type: 'tool_call',
callId: 'nested',
tool: 'bash',
input: { items: ['ééééé', null, true], n: 1 },
truncated: true,
})
})
it('keeps raw arguments that JSON cannot round-trip, such as an overflowing number', () => {
const test = harness({}, 's1')
test.emitSession({
type: 'tool/call',
data: { turn: 1, step: 1, callId: 'inf', name: 'bash', arguments: '{"n":1e400}' },
} as unknown as SessionEvent)
expect(test.parsed()[1]).toEqual({ type: 'tool_call', callId: 'inf', tool: 'bash', input: '{"n":1e400}' })
})
it('keeps a literal __proto__ key and bounds over-long object keys', () => {
const proto = harness({ maxStringBytes: 32 }, 's1')
proto.emitSession({
type: 'tool/call',
data: {
turn: 1,
step: 1,
callId: 'p',
name: 'bash',
arguments: '{"__proto__":{"polluted":true},"a":1}',
},
} as unknown as SessionEvent)
const protoInput = proto.parsed()[1]?.input as Record<string, unknown>
expect(Object.keys(protoInput)).toEqual(['__proto__', 'a'])
expect(protoInput['__proto__']).toEqual({ polluted: true })
const longKey = harness({ maxStringBytes: 8 }, 's1')
longKey.emitSession({
type: 'tool/call',
data: {
turn: 1,
step: 1,
callId: 'k',
name: 'bash',
arguments: JSON.stringify({ ['k'.repeat(20)]: 1 }),
},
} as unknown as SessionEvent)
const longEvent = longKey.parsed()[1] as { input: Record<string, unknown>; truncated?: boolean }
expect(Object.keys(longEvent.input)).toEqual(['kkkkkkkk'])
expect(longEvent.truncated).toBe(true)
})
it('drops a split trailing multibyte character when truncating', () => {
const test = harness({ maxStringBytes: 5 }, 's1')
test.emitSession(assistantMessage([{ type: 'text', text: 'ééé' }]))
expect(test.parsed()[1]).toEqual({ type: 'text', text: 'éé', truncated: true })
})
it('normalizes empty tool arguments to an empty object like the executor', () => {
const test = harness({}, 's1')
test.emitSession({
type: 'tool/call',
data: { turn: 1, step: 1, callId: 'empty', name: 'bash', arguments: '' },
} as unknown as SessionEvent)
expect(test.parsed()[1]).toEqual({ type: 'tool_call', callId: 'empty', tool: 'bash', input: {} })
})
it('bounds one whole event line, dropping structured fields before scalars', () => {
const input = Array.from({ length: 20_000 }, (_, index) => index)
const line = boundJsonLine({ type: 'tool_call', callId: 'c', tool: 'bash', input }, MAX_STRING_BYTES, 1024)
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(1024)
expect(JSON.parse(line)).toEqual({ type: 'tool_call', callId: 'c', tool: 'bash', truncated: true })
})
it('reduces a payload to its type when even its scalars exceed the line cap', () => {
const line = boundJsonLine({ type: 'text', text: 'x'.repeat(2000) }, 4096, 64)
expect(JSON.parse(line)).toEqual({ type: 'text', truncated: true })
expect(JSON.parse(boundJsonLine({ type: 'text', text: 'ok' }))).toEqual({ type: 'text', text: 'ok' })
})
it('caps an error line even when control characters expand under JSON escaping', () => {
const line = boundJsonLine({ type: 'error', message: '\u0000'.repeat(MAX_STRING_BYTES) })
expect(Buffer.byteLength(line, 'utf8') + 1).toBeLessThanOrEqual(32 * 1024)
expect(JSON.parse(line)).toEqual({ type: 'error', truncated: true })
})
it('reserves the trailing newline inside the whole-line cap', () => {
// A 64-byte line exactly fills a 64-byte cap; the writer's newline must
// force the scalar fallback rather than write 65 bytes.
const line = boundJsonLine({ type: 'text', text: 'x'.repeat(39) }, 4096, 64)
expect(Buffer.byteLength(line, 'utf8') + 1).toBeLessThanOrEqual(64)
expect(JSON.parse(line)).toEqual({ type: 'text', truncated: true })
})
it('cuts a payload that nests past the depth budget instead of overflowing the stack', () => {
let deepArray: unknown = 'leaf'
for (let level = 0; level < 200; level += 1) deepArray = [deepArray]
expect(JSON.parse(boundJsonLine({ type: 'tool_call', input: deepArray })))
.toMatchObject({ type: 'tool_call', truncated: true })
let deepObject: unknown = 'leaf'
for (let level = 0; level < 200; level += 1) deepObject = { next: deepObject }
expect(JSON.parse(boundJsonLine({ type: 'tool_call', input: deepObject })))
.toMatchObject({ type: 'tool_call', truncated: true })
})
it('sums retried attempt usage into the step total and drops an unshared bucket', () => {
const test = harness()
test.emitSession(attemptWithUsage({ inputTokens: 10, outputTokens: 2, totalTokens: 12, cacheReadTokens: 4 }))
test.emitSession(assistantMessage(
[{ type: 'text', text: 'ok' }],
{ inputTokens: 3, outputTokens: 1, totalTokens: 4 },
))
test.emitSession(stepEnd())
expect(test.parsed().at(-1)).toEqual({
type: 'status', phase: 'step_end', turn: 1, step: 1,
usage: { inputTokens: 13, outputTokens: 3, totalTokens: 16 },
})
})
it('sums every optional bucket both attempts report', () => {
const test = harness()
const sample = {
inputTokens: 2, outputTokens: 1, totalTokens: 3,
cacheReadTokens: 1, cacheWriteTokens: 1, reasoningTokens: 1,
}
test.emitSession(attemptWithUsage(sample))
test.emitSession(attemptWithUsage(sample))
test.emitSession(stepEnd())
expect(test.parsed().at(-1)).toMatchObject({
usage: {
inputTokens: 4, outputTokens: 2, totalTokens: 6,
cacheReadTokens: 2, cacheWriteTokens: 2, reasoningTokens: 2,
},
})
})
it('drops a bucket only the later attempt reports', () => {
const test = harness()
test.emitSession(attemptWithUsage({ inputTokens: 1, outputTokens: 1 }))
test.emitSession(attemptWithUsage({ inputTokens: 1, outputTokens: 1, cacheWriteTokens: 2 }))
test.emitSession(stepEnd())
const usage = (test.parsed().at(-1) as { usage: Record<string, unknown> }).usage
expect(usage).toEqual({ inputTokens: 2, outputTokens: 2 })
expect(usage).not.toHaveProperty('cacheWriteTokens')
})
it('reads a message usage sample from its stream when the field is absent', () => {
const test = harness()
test.emitSession({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
stream: [{ type: 'chunk', chunk: { type: 'usage', usage: { inputTokens: 5, outputTokens: 1 } } }],
message: {
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'model', provider: 'p', model: 'm' },
},
},
} as unknown as SessionEvent)
test.emitSession(stepEnd())
expect(test.parsed().at(-1)).toMatchObject({ usage: { inputTokens: 5, outputTokens: 1 } })
})
it('omits the step total when a later attempt reports no usage sample', () => {
const test = harness()
test.emitSession(attemptWithUsage({ inputTokens: 7, outputTokens: 3 }))
test.emitSession(assistantMessage([{ type: 'text', text: 'ok' }]))
test.emitSession(stepEnd())
expect(test.parsed().at(-1)).toEqual({ type: 'status', phase: 'step_end', turn: 1, step: 1 })
})
it('omits the step total when an earlier attempt reports no usage sample', () => {
const test = harness()
test.emitSession({
type: 'assistant/attempt',
data: { turn: 1, step: 1, stream: [] },
} as unknown as SessionEvent)
test.emitSession(assistantMessage([{ type: 'text', text: 'ok' }], { inputTokens: 1, outputTokens: 1 }))
test.emitSession(stepEnd())
expect(test.parsed().at(-1)).toEqual({ type: 'status', phase: 'step_end', turn: 1, step: 1 })
})
it('omits the step total when no attempt reports a usage sample', () => {
const test = harness()
test.emitSession(stepEnd())
expect(test.parsed().at(-1)).toEqual({ type: 'status', phase: 'step_end', turn: 1, step: 1 })
})
it('writes the terminal final event without bounding its answer', () => {
const test = harness({ maxStringBytes: 4 })
test.projection.finish('abcdefgh')
expect(test.parsed().at(-1)).toEqual({ type: 'final', text: 'abcdefgh' })
})
it('ignores events from another Session and stops writing after dispose', () => {
const test = harness()
test.emitRawSession({}, assistantMessage([{ type: 'text', text: 'foreign' }]))
test.projection.dispose()
test.emitSession(assistantMessage([{ type: 'text', text: 'late' }]))
test.projection.finish('ignored')
expect(test.parsed().map(event => event.type)).toEqual(['session'])
})
it('bounds one projected payload through the line writer', () => {
expect(JSON.parse(boundJsonLine({ type: 'error', message: 'x'.repeat(20) }, 8, 4096)))
.toEqual({ type: 'error', message: 'xxxxxxxx', truncated: true })
expect(JSON.parse(boundJsonLine({ type: 'error', message: 'ok' }))).toEqual({ type: 'error', message: 'ok' })
})
})
+145 -15
View File
@@ -1,7 +1,7 @@
/**
* The one-shot app's ordinary command-line provider over a real Loader tree:
* the task becomes injected runner config, while help and usage errors leave
* the consumer pending.
* the task, exact Session identity, and output mode become injected runner
* config, while help and interactive usage errors leave the consumer pending.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -11,38 +11,53 @@ import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { internals as cmdlineInternals, provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { afterEach, describe, expect, it } from 'vitest'
import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts'
import {
apply,
HEADLESS_STARTUP_SERVICE,
type HeadlessStartupValues,
} from '../src/startup.ts'
import { internals as startupInternals } from '../src/startup-internals.ts'
/** What one boot of the fixture tree observed. */
interface Observed {
exits: number[]
out: string
err: string
runnerConfig?: unknown
}
const disposers: (() => Promise<void>)[] = []
/** The real process facts captured before any test substitutes them. */
const originalInternals = { ...startupInternals }
/** Fixture tree roots, removed after their booted tree has been disposed. */
const tempDirs: string[] = []
afterEach(async () => {
for (const dispose of disposers.splice(0)) await dispose()
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
internals.stdout = process.stdout
internals.stderr = process.stderr
cmdlineInternals.stdout = process.stdout
cmdlineInternals.stderr = process.stderr
startupInternals.stdinIsTty = () => process.stdin.isTTY
startupInternals.stdout = process.stdout
})
/**
* Mount the real provider over a runner stand-in.
* @param args - the invocation's inner arguments.
* @param options - process facts the provider reads.
* @returns the resolved service value and observed runner/process effects.
*/
async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
async function bootStartup(
args: string[],
options: { stdinIsTty?: boolean } = {},
): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-'))
tempDirs.push(dir)
const observed: Observed = { exits: [], out: '' }
const observed: Observed = { exits: [], out: '', err: '' }
writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n')
// Loader imports through Node's resolver, so this fixture delegates to the
// source-plane plugin already imported by the test.
@@ -58,13 +73,26 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx)
` inject: [${HEADLESS_STARTUP_SERVICE}]`,
' config:',
' task: !!js ctx.headlessStartup.task',
' sessionId: !!js ctx.headlessStartup.sessionId',
' json: !!js ctx.headlessStartup.json',
'- id: headless-startup',
` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`,
'',
].join('\n'))
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
internals.stdout = observing
internals.stderr = observing
// Commander's own output keeps landing in `out` so existing assertions see
// the full transcript, while `err` isolates what stderr actually carried.
const observingErr = {
write: (chunk: string) => {
observed.out += chunk
observed.err += chunk
return true
},
}
cmdlineInternals.stdout = observing
cmdlineInternals.stderr = observingErr
startupInternals.stdinIsTty = () => options.stdinIsTty === true
startupInternals.stdout = observing
const globals = globalThis as unknown as {
__headlessStartupApply: typeof apply
__headlessStartupObserved: Observed
@@ -88,23 +116,125 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx)
describe('headless command-line provider', () => {
it('joins the task positional into the runner config', async () => {
const { task, observed } = await bootStartup(['run', 'the', 'tests'])
expect(task).toEqual({ task: 'run the tests' })
expect(observed.runnerConfig).toEqual({ task: 'run the tests' })
expect(task).toEqual({ task: 'run the tests', sessionId: undefined, json: false })
expect(observed.runnerConfig).toMatchObject({ task: 'run the tests', json: false })
expect(observed.exits).toEqual([])
})
it.each([{ args: [] }, { args: [' '] }])('rejects an invocation with no non-whitespace task ($args)', async ({ args }) => {
const { task, observed } = await bootStartup(args)
it('publishes the machine-readable output mode and the exact Session identity', async () => {
const { task, observed } = await bootStartup(['--json', '--session-id', 'session-exact', 'do', 'it'])
expect(task).toEqual({ task: 'do it', sessionId: 'session-exact', json: true })
expect(observed.runnerConfig).toMatchObject({ task: 'do it', sessionId: 'session-exact', json: true })
})
it('keeps the stdin marker as the task so the runner reads the pipe', async () => {
const { task } = await bootStartup(['-'], { stdinIsTty: false })
expect(task).toEqual({ task: '-', sessionId: undefined, json: false })
})
it('defers an absent task to stdin when stdin is not a terminal', async () => {
const { task, observed } = await bootStartup([], { stdinIsTty: false })
expect(task).toEqual({ task: undefined, sessionId: undefined, json: false })
expect(observed.runnerConfig).toMatchObject({ json: false })
})
it.each([{ args: [] as string[] }, { args: [' '] }])('rejects an interactive invocation with no task ($args)', async ({ args }) => {
const { task, observed } = await bootStartup(args, { stdinIsTty: true })
expect(observed.out).toContain('a task is required')
expect(task).toBeUndefined()
expect(observed.runnerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('rejects an explicitly empty Session identity', async () => {
const { task, observed } = await bootStartup(['--session-id', '', 'do', 'it'])
expect(observed.out).toContain('--session-id requires a non-empty session id')
expect(task).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('keeps the caller-provided exact Session identity verbatim', async () => {
const { task } = await bootStartup(['--session-id', ' session-x ', 'do', 'it'])
expect(task).toEqual({ task: 'do it', sessionId: ' session-x ', json: false })
})
it('rejects a lone stdin marker mixed with other task words', async () => {
const { task, observed } = await bootStartup(['-', 'do', 'it'])
expect(observed.out).toContain('`-` must be the only task argument')
expect(task).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('writes the JSON error event for a --json usage error', async () => {
const { task, observed } = await bootStartup(['--json'], { stdinIsTty: true })
const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
expect(first).toEqual({
type: 'error',
message: 'a task is required, for example: dsh --profile headless "run the tests"',
})
expect(task).toBeUndefined()
expect(observed.err).toBe('')
expect(observed.exits).toEqual([1])
})
it('writes the JSON error event for an empty Session identity in --json mode', async () => {
const { observed } = await bootStartup(['--json', '--session-id', '', 'do', 'it'])
const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
expect(first.type).toBe('error')
expect(first.message).toContain('--session-id requires a non-empty session id')
expect(observed.err).toBe('')
expect(observed.exits).toEqual([1])
})
it('writes the JSON error event for a commander grammar rejection in --json mode', async () => {
const { task, observed } = await bootStartup(['--json', '--bogus', 'do', 'it'])
const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
expect(first).toEqual({ type: 'error', message: "unknown option '--bogus'" })
expect(task).toBeUndefined()
expect(observed.err).toBe('')
expect(observed.exits).toEqual([1])
})
it('does not install the JSON error override for a --json option value', async () => {
const { observed } = await bootStartup(['--session-id', '--json'], { stdinIsTty: true })
expect(observed.out).toContain('a task is required')
expect(observed.out).not.toContain('"type":"error"')
expect(observed.exits).toEqual([1])
})
it('does not install the JSON error override for a --json positional after --', async () => {
const { task, observed } = await bootStartup(['--', '--json'], { stdinIsTty: false })
expect(task).toEqual({ task: '--json', sessionId: undefined, json: false })
expect(observed.out).not.toContain('"type":"error"')
})
it('rejects a blank positional task instead of reading stdin', async () => {
const { task, observed } = await bootStartup([' '], { stdinIsTty: false })
expect(observed.out).toContain('a task is required')
expect(task).toBeUndefined()
expect(observed.runnerConfig).toBeUndefined()
expect(observed.exits).toEqual([1])
})
it('reports the real process stdin terminal state by default', () => {
const original = Object.getOwnPropertyDescriptor(process, 'stdin')
Object.defineProperty(process, 'stdin', { value: { isTTY: true }, configurable: true })
try {
expect(originalInternals.stdinIsTty()).toBe(true)
} finally {
if (original !== undefined) Object.defineProperty(process, 'stdin', original)
}
})
it('fails loud without the launcher command line and exit request', () => {
expect(() => { apply(new Context()) }).toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit')
})
it('prints its own help and leaves the runner pending', async () => {
const { task, observed } = await bootStartup(['--help'])
expect(observed.out).toContain('dsh --profile headless')
expect(observed.out).toContain('stream reasoning to stderr')
expect(observed.out).toContain('the answer goes to stdout and diagnostics to stderr')
expect(observed.out).toContain('--session-id')
expect(task).toBeUndefined()
expect(observed.runnerConfig).toBeUndefined()
expect(observed.exits).toEqual([0])
+3
View File
@@ -31,6 +31,9 @@
},
{
"path": "../../boot/cmdline"
},
{
"path": "../../session-query/session-query"
}
]
}
@@ -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/test-support/loader-smoke/README.md
README.md: dd618358aeb6dca2fbaf3a4945d09aa529b28f48
README.zh.md: bc86a92ed867fbd33f1e1c3aebb6b4ab5aefc7ce
README.md: 4365cc16beadc517390dd4ce8616cdfb6ec9e7be
README.zh.md: 3d9659acf7b845bab11be3a5f6c0ff37e15b8988
+2 -2
View File
@@ -29,7 +29,7 @@ This package boots an application fixture the way an installed consumer would an
### Booting an application fixture
`runLoaderSmoke` takes bin and config paths, optional complete bin arguments, environment overrides, stdin, pre-run setup, and pre-cleanup inspection. It owns the isolated cwd, DSH homes, diagnostics, deadline, termination, EOF, and cleanup, and returns both streams after a zero exit or rejects with both streams on failure:
`runLoaderSmoke` takes bin and config paths, optional complete bin arguments, environment overrides, stdin, pre-run setup, and pre-cleanup inspection. It owns the isolated cwd, DSH homes, diagnostics, deadline, termination, EOF, and cleanup of a cwd it created (a caller-provided cwd is left in place), and returns both streams after a zero exit or rejects with both streams on failure:
```text
const result = await runLoaderSmoke({
@@ -73,7 +73,7 @@ This section explains the design of the harness; the observable behavior is full
### Design
The harness is built on one separation: the smoke runs in a child process under an isolated world, and the test process only observes and asserts. `runLoaderSmoke` creates a temporary cwd, prepares world state there, spawns the resolved bin with isolated DSH homes (`DSH_HOME`, `DSH_AGENTS_HOME` under the temp cwd), closes stdin immediately, and awaits a clean exit within the deadline before inspecting and cleaning up on every outcome. `runFixtureTurn` stays in-process: it looks up the composition's single root agent, follows the task from its durable inbox receipt through whole-agent idle, sums per-step usage, and flushes the session before returning.
The harness is built on one separation: the smoke runs in a child process under an isolated world, and the test process only observes and asserts. `runLoaderSmoke` creates a temporary cwd (or reuses a caller-provided one), prepares world state there, spawns the resolved bin with isolated DSH homes (`DSH_HOME`, `DSH_AGENTS_HOME` under that cwd), closes stdin immediately, and awaits a clean exit within the deadline before inspecting on every outcome and removing only a cwd it created. `runFixtureTurn` stays in-process: it looks up the composition's single root agent, follows the task from its durable inbox receipt through whole-agent idle, sums per-step usage, and flushes the session before returning.
### Source map
@@ -29,7 +29,7 @@ kind: "package-library"
### 启动应用 fixture
`runLoaderSmoke` 接受可执行文件与配置路径、可选的完整可执行文件参数、环境覆盖、标准输入、运行前准备与清理前检查。它负责隔离工作目录、DSH 主目录、诊断、截止时间、终止、EOF 与清理;进程以零状态退出后返回两个流,失败时则拒绝并附带两个流:
`runLoaderSmoke` 接受可执行文件与配置路径、可选的完整可执行文件参数、环境覆盖、标准输入、运行前准备与清理前检查。它负责隔离工作目录、DSH 主目录、诊断、截止时间、终止、EOF 与清理(只清理自己创建的 cwd,调用方自带的 cwd 原样保留);进程以零状态退出后返回两个流,失败时则拒绝并附带两个流:
```text
const result = await runLoaderSmoke({
@@ -73,7 +73,7 @@ Profile 集成 driver 使用仅限仓库内部的 `tests/fixtures/production-pro
### 设计
harness 建立在一个分离之上:冒烟测试在隔离世界中的子进程里运行,测试进程只观察与断言。`runLoaderSmoke` 创建临时 cwd、在那里准备世界状态、以隔离的 DSH 主目录(临时 cwd 下的 `DSH_HOME`、`DSH_AGENTS_HOME`)spawn 解析出的可执行文件、立即关闭 stdin,并在截止时间内等待干净退出,然后在每种结果下都执行检查与清理。`runFixtureTurn` 留在进程内运行:它查找组合中的唯一根 agent,从持久收件箱收到任务起持续跟踪,直至整个 agent 完全停稳;随后汇总每步用量,并在返回前刷写会话。
harness 建立在一个分离之上:冒烟测试在隔离世界中的子进程里运行,测试进程只观察与断言。`runLoaderSmoke` 创建临时 cwd(或复用调用方提供的 cwd)、在那里准备世界状态、以隔离的 DSH 主目录(该 cwd 下的 `DSH_HOME`、`DSH_AGENTS_HOME`)spawn 解析出的可执行文件、立即关闭 stdin,并在截止时间内等待干净退出,然后在每种结果下都执行检查,且只删除自己创建的 cwd。`runFixtureTurn` 留在进程内运行:它查找组合中的唯一根 agent,从持久收件箱收到任务起持续跟踪,直至整个 agent 完全停稳;随后汇总每步用量,并在返回前刷写会话。
### 源码地图
+39 -10
View File
@@ -130,14 +130,10 @@ export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaun
return { command: process.execPath, args: [options.libBin ?? toLibBin(options.srcBin), ...configArgs], env }
}
/** Inputs that vary between real-Loader example smokes. */
export interface LoaderSmokeOptions {
/** Inputs every real-Loader example smoke supplies. */
interface LoaderSmokeBaseOptions {
/** Human-readable example name used in failure diagnostics. */
readonly label: string
/** Prefix for the isolated temporary process cwd. */
readonly tempDirPrefix: string
/** Existing parent for the generated cwd; defaults to the platform temporary directory. */
readonly tempDirParent?: string
/** Absolute app-bin source path (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
readonly binScript: string
/** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */
@@ -167,6 +163,27 @@ export interface LoaderSmokeOptions {
readonly expectedExitCode?: number
}
/**
* Inputs that vary between real-Loader example smokes. The cwd is either one
* the harness expands from a prefix and owns, or a caller-provided directory it
* reuses and leaves in place; the two cannot be combined.
*/
export type LoaderSmokeOptions = LoaderSmokeBaseOptions & (
| {
/** Prefix for the isolated temporary process cwd. */
readonly tempDirPrefix: string
/** Existing parent for the generated cwd; defaults to the platform temporary directory. */
readonly tempDirParent?: string
readonly cwd?: never
}
| {
/** Existing directory to use as the process cwd; the caller owns its cleanup. */
readonly cwd: string
readonly tempDirPrefix?: never
readonly tempDirParent?: never
}
)
/** Captured output from a Loader smoke that exited successfully. */
export interface LoaderSmokeResult {
/** Complete stdout after clean exit. */
@@ -175,15 +192,27 @@ export interface LoaderSmokeResult {
readonly stderr: string
}
/** Whether the options supply the cwd instead of a prefix the harness expands. */
function hasProvidedCwd(
options: LoaderSmokeOptions,
): options is LoaderSmokeBaseOptions & { readonly cwd: string } {
return options.cwd !== undefined
}
/**
* Boot one real Loader tree from an isolated cwd, close stdin immediately, and
* await a clean exit. The helper owns process kill and temp-directory cleanup on
* every outcome, and picks src/lib via {@link resolveExampleLaunch}.
* await a clean exit. The helper owns process kill on every outcome and removes
* the temporary directory it created; a caller-provided cwd is left in place so
* consecutive smokes can share one world. It picks src/lib via
* {@link resolveExampleLaunch}.
* @param options - example paths, mode, environment, and diagnostic identity.
* @returns captured stdout and stderr after a zero exit.
*/
export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
const cwd = await mkdtemp(join(options.tempDirParent ?? tmpdir(), options.tempDirPrefix))
const providedCwd = hasProvidedCwd(options)
const cwd = providedCwd
? options.cwd
: await mkdtemp(join(options.tempDirParent ?? tmpdir(), options.tempDirPrefix))
const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
try {
await options.prepare?.(cwd)
@@ -222,6 +251,6 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
await options.inspect?.(cwd)
return { stdout: result.stdout, stderr: result.stderr }
} finally {
await rm(cwd, { recursive: true, force: true })
if (!providedCwd) await rm(cwd, { recursive: true, force: true })
}
}
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -43,6 +44,25 @@ describe('runLoaderSmoke', () => {
expect(existsSync(output.cwd)).toBe(false)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('reuses a caller-provided cwd and leaves it in place', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'loader-smoke-shared-'))
try {
const result = await runLoaderSmoke({
label: 'shared cwd fixture',
cwd,
binScript: fixture('success'),
configPath,
tsconfigPath,
mode: 'src',
})
const output = JSON.parse(result.stdout) as { cwd: string }
expect(canonicalTempPath(output.cwd)).toBe(canonicalTempPath(cwd))
expect(existsSync(cwd)).toBe(true)
} finally {
await rm(cwd, { recursive: true, force: true })
}
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('passes an arbitrary bin argv and inspects world state before cleanup', async () => {
let inspected = ''
let marker = ''
+3
View File
@@ -1641,6 +1641,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../../session-query/session-query
packages/bundle/sdk-app:
dependencies:
+3
View File
@@ -185,6 +185,9 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// through a hashed chunk. The committed bin.js is the link target pnpm can
// resolve at install time, before the build produces lib/bin.js.
'@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
// The headless entry and its startup row share the JSON projection code
// through a hashed tsdown chunk; both import it by relative path.
'@deepseek-ai/dsh-headless': ['lib/json-stream-*.js'],
}
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {