Browse Source

fix(headless): project committed content and scope live adoption

lsdsjy 1 month ago
parent
commit
ba6e19809d

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml

@@ -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 .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md
-2026-09-09-headless-machine-readable-run-surface.md: b188c0afee35b3cbb17c374f61194126d1e85be7
-2026-09-09-headless-machine-readable-run-surface.zh.md: 47cf843a612a174564f766265bb599e948b9527a
+2026-09-09-headless-machine-readable-run-surface.md: c55f0b96313cfe792e845396b7049249f4ca43ee
+2026-09-09-headless-machine-readable-run-surface.zh.md: 7826fe0435acba74e7156bdd2a3abd9b0372cf7f

+ 11 - 9
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md

@@ -42,18 +42,19 @@ Task resolution order: joined positionals, then `-`, then piped stdin. A termina
 |---|---|---|
 | `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` | assistant text delta |
-| `thinking` | `text` | reasoning delta |
+| `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 call |
+| `tool_result` | `callId`, `status`, `result` | once per appended result |
 | `error` | `message` | process-level failure outside a turn |
 | `final` | `text` | last line |
 
 Projection rules:
 
-- Assistant text and reasoning deltas coalesce until 100 ms or 512 bytes accumulate, so a streaming reply stays live without one line per token.
-- The assembled assistant message is not repeated after its deltas. `user/message` echoes and internal session events (title, model selection, projection, checkpoint, goal, subagent) are not projected.
-- Every projected string is bounded at 8 KiB; an event with a cut string carries `truncated: true`. `tool_result` carries no spill path, because the tool already appends its own truncation notice to the result text.
+- 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 is bounded at 8 KiB, and an event with a cut string carries `truncated: true`, including the process-level `error` event. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
 - `usage` appears on `step_end`, matching the token accounting a provider reports per step.
 - Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary.
 
@@ -63,7 +64,7 @@ The runtime owns identity. A run without `--session-id` mints `session-<uuid>` a
 
 `--session-id <id>` is adopt-or-create: observe the persisted session, resume it when it exists, create it otherwise. Create-only would fail the second run, because the JSONL store rejects an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)).
 
-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. A session linked to a parent or subagent is rejected. 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.
+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. A session linked to a parent or subagent is rejected. The same two checks run when a live Agent already holds the requested id, so a live identity cannot bypass them. 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.
 
 ## Consequences
 
@@ -71,7 +72,8 @@ What landed: `src/startup.ts` parses `--json` and `--session-id <id>`, treats an
 
 - 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.
-- Two consecutive runs with the same `--session-id` share history. A run whose cwd differs from the persisted session exits 1 with a diagnostic.
+- 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. A run whose cwd differs from the persisted session exits 1 with a diagnostic, whether the identity is live or persisted.
 - A piped task with no positional task is honored, 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.
 
@@ -80,7 +82,7 @@ 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 cap is owned by `src/json-stream.ts` and should stay a single constant.
+- Bounded `tool_result` payloads hide full output from the supervisor; the 8 KiB cap is owned by `src/json-stream.ts` and should stay a single constant, with the terminal `final` event the only exemption.
 
 ## Alternatives considered
 

+ 11 - 9
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md

@@ -42,18 +42,19 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 |---|---|---|
 | `session` | `sessionId`、`cwd` | 第一行,先于任何模型输出 |
 | `status` | `phase`(`turn_start`、`step_start`、`step_end`、`turn_end`)、`turn`、`step`、`usage`、`reason` | 每个边界一条 |
-| `text` | `text` | 助手文本增量 |
-| `thinking` | `text` | 推理增量 |
+| `text` | `text` | 一个已提交的助手文本块 |
+| `thinking` | `text` | 一个已提交的推理块 |
 | `tool_call` | `callId`、`tool`、`input` | 每次调用一条 |
-| `tool_result` | `callId`、`status`、`result` | 每次调用一条 |
+| `tool_result` | `callId`、`status`、`result` | 每次追加的结果一条 |
 | `error` | `message` | 轮次之外的进程级失败 |
 | `final` | `text` | 最后一行 |
 
 投影规则:
 
-- 助手文本与推理增量按 100 ms 或 512 字节合并,因此流式回复保持实时,又不会每个 token 一行。
-- 增量发完之后不再重复整条助手消息。`user/message` 回显和内部会话事件(标题、模型选择、投影、检查点、目标、子 agent)都不投影。
-- 每个被投影的字符串都限制在 8 KiB;被截断的事件带 `truncated: true`。`tool_result` 不带 spill 路径,因为工具已经把自己的截断提示追加到结果文本里。
+- 文本与推理只从已提交的 `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`,进程级 `error` 事件同样如此。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
 - `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。
 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。
 
@@ -63,7 +64,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 
 `--session-id <id>` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。
 
-采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话。带父会话或子 agent 关联的会话被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败。
+采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话。带父会话或子 agent 关联的会话被拒绝。当某个存活 Agent 已经持有请求的 id 时,同样执行这两项检查,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败。
 
 ## 后果
 
@@ -71,7 +72,8 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 
 - 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。
 - `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。
-- 两次连续的相同 `--session-id` 运行共享历史。cwd 与持久化会话不一致的运行以诊断退出 1。
+- 发生重试的步骤只为最终提交的 attempt 发布 `text` 与 `thinking`,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。
+- 两次连续的相同 `--session-id` 运行共享历史。cwd 与持久化会话不一致的运行以诊断退出 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 期望测试端到端覆盖两种输出模式。
 
@@ -80,7 +82,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 - 每次运行的 `--model` 覆盖尚未实现。后续改动必须尊重 Session Controller 拥有的会话局部选择优先级,而不是覆盖已保存的选择。
 - 冷启动加上日志重放会随会话变长而增长,因此长会话每次唤醒的代价高于新会话。
 - `--json` 把推理从 stderr 移到 stdout,因此只监听 stderr 的日志收集器在该模式的有推理运行上什么都看不到。
-- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 上限由 `src/json-stream.ts` 拥有,应保持单一常量。
+- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 上限由 `src/json-stream.ts` 拥有,应保持单一常量,终止 `final` 事件是唯一的例外。
 
 ## 备选方案
 

+ 2 - 2
packages/bundle/headless/README.i18n.yaml

@@ -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: bafe5fddeca801b06e3227cd7df5ed0401e7dd25
-README.zh.md: 1cd2badf6a7ecaf4a7ce8f6ac8f4ced41af51cf5
+README.md: 484d975135dcf494fb05bdc41b5413596b57b40f
+README.zh.md: 1206e793923826810f5cc9cc812cb47fbc979130

+ 6 - 6
packages/bundle/headless/README.md

@@ -33,10 +33,10 @@ Run one task, get the final answer, and exit. The task is the command-line argum
 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. The task comes from the positional argument, or from stdin when the argument is omitted or is `-`; a blank argument or an empty pipe is rejected before anything runs.
+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 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:
 
 ```sh
-git diff --stat | dsh --profile headless "summarize these changes"
+{ echo "Summarize these changes:"; git diff --stat; } | dsh --profile headless
 ```
 
 The task and run options are supplied through three settings:
@@ -55,7 +55,7 @@ Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-
 
 ### 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. Streamed text and thinking deltas are coalesced before they are written, and every string is capped at 8 KiB and flagged with `truncated`. A process-level failure outside a turn is reported as an `error` event on stdout in addition to the `dsh:` stderr line.
+`--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. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string is capped at 8 KiB and flagged with `truncated`. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line.
 
 ### When to use it
 
@@ -93,11 +93,11 @@ A completed final `turn/end` exits 0; any other outcome — aborted, error, or n
 |---|---|
 | [`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, coalescing, string bounding |
+| [`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, session adoption, and exit mapping |
-| [`tests/json-stream.spec.ts`](tests/json-stream.spec.ts) | Projection ordering, coalescing, bounding, and disposal |
+| [`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
@@ -143,7 +143,7 @@ These limits tell you when headless does not fit and what it needs from the `dsh
 - **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- and ownership-scoped** — `--session-id` refuses a Session recorded in another working directory or owned by a subagent, and requires the composed Session query service.
-- **The event stream is a projection, not the log** — `--json` coalesces deltas and caps strings at 8 KiB, so it is not a lossless copy of the Session log.
+- **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

+ 6 - 6
packages/bundle/headless/README.zh.md

@@ -33,10 +33,10 @@ kind: "package-bundle"
 dsh --profile headless "run the tests"
 ```
 
-agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: <code>: <message>` 向 stderr 写入错误。任务来自位置参数,参数省略或为 `-` 时则来自 stdin;空白参数或空管道会在任何内容运行前被拒绝。
+agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: <code>: <message>` 向 stderr 写入错误。任务来自位置参数,参数省略或为单独的 `-` 时则来自 stdin;空白参数或空管道会在任何内容运行前被拒绝。位置参数会原样作为任务,stdin 不会被读取,因此想让管道内容进入提示词时,请把完整提示写进管道:
 
 ```sh
-git diff --stat | dsh --profile headless "summarize these changes"
+{ echo "Summarize these changes:"; git diff --stat; } | dsh --profile headless
 ```
 
 任务与运行选项通过三个设置提供:
@@ -55,7 +55,7 @@ git diff --stat | dsh --profile headless "summarize these changes"
 
 ### 机器可读输出
 
-`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。流式的 text 与 thinking 增量在写出前会被合并,每个字符串上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败除了 stderr 的 `dsh:` 行外,还会在 stdout 上以 `error` 事件报告。
+`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。
 
 ### 何时使用
 
@@ -93,11 +93,11 @@ patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-promp
 |---|---|
 | [`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` 投影:事件词汇、合并、字符串限长 |
+| [`src/json-stream.ts`](src/json-stream.ts) | `--json` 投影:事件词汇、提交点发射、字符串限长 |
 | [`cordis.patch.yml`](cordis.patch.yml) | 叠加在 `dsh-base` 之上的一次性 patch |
 | — | 不发布运行时不变式伴生入口;可观察的行为属于进程级组合,本包只持有静态 patch 列表。 |
 | [`tests/headless.spec.ts`](tests/headless.spec.ts) | 运行流程、汇总、flush、Session 沿用与退出映射 |
-| [`tests/json-stream.spec.ts`](tests/json-stream.spec.ts) | 投影顺序、合并、限长与释放 |
+| [`tests/json-stream.spec.ts`](tests/json-stream.spec.ts) | 投影顺序、提交点发射、限长与释放 |
 | [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 |
 
 ### 不变式归属
@@ -143,7 +143,7 @@ runner 不向请求前缀添加任何内容;它只是把一条用户消息驱
 - **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。
 - **默认 stdout 只承载最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印,除非显式启用 `--json`。
 - **沿用受 cwd 与归属限制**——`--session-id` 会拒绝记录在其他工作目录或由 subagent 拥有的 Session,并要求已组合的 Session 查询服务。
-- **事件流是投影而非日志**——`--json` 会合并增量并把字符串限制在 8 KiB,因此它不是 Session 日志的无损副本。
+- **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 25 - 10
packages/bundle/headless/src/index.ts

@@ -28,7 +28,7 @@ import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
 import type {} from '@deepseek-ai/cordis-plugin-loader'
 import type {} from '@deepseek-ai/dsh-cmdline'
 import type {} from '@deepseek-ai/dsh-session-query'
-import { projectJsonRun } from './json-stream.ts'
+import { projectJsonRun, boundJsonEvent } from './json-stream.ts'
 
 /** Stable Cordis plugin name. */
 export const name = 'headless-runner'
@@ -175,6 +175,23 @@ 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
+}
+
+/** Reject a Session the one-shot runner must not adopt. */
+function assertAdoptable(header: AdoptableHeader, sessionId: SessionId): void {
+  if (header.origin === 'subagent' || header.parentSession !== undefined) {
+    throw new Error(`session "${sessionId}" belongs to a subagent and cannot be driven directly`)
+  }
+  if (header.cwd !== process.cwd()) {
+    throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${process.cwd()}"`)
+  }
+}
+
 /**
  * Resolve the Agent for one run: reuse a live identity, adopt the persisted
  * Session with the requested id, or create that exact id when no log exists.
@@ -193,20 +210,18 @@ async function resolveAgent(
   setup: (agentCtx: Context) => void,
 ): Promise<Agent> {
   const live = agents.get(sessionId)
-  if (live !== undefined) return live
+  if (live !== undefined) {
+    // A live identity skips adoption, not the rules that make adoption safe.
+    assertAdoptable(live.session.header, sessionId)
+    return live
+  }
   const query = ctx.get('sessionQuery')
   if (query === undefined) {
     throw new Error('headless --session-id requires the sessionQuery service; dsh-base provides it')
   }
   try {
     using observation = await query.observeSession(sessionId)
-    const header = observation.header
-    if (header.origin === 'subagent' || header.parentSession !== undefined) {
-      throw new Error(`session "${sessionId}" belongs to a subagent and cannot be driven directly`)
-    }
-    if (header.cwd !== process.cwd()) {
-      throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${process.cwd()}"`)
-    }
+    assertAdoptable(observation.header, sessionId)
     const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup })
     return agent
   } catch (error: unknown) {
@@ -224,7 +239,7 @@ async function resolveAgent(
 /** Report an unexpected direct-driver failure and request a failing exit. */
 function fail(io: HeadlessIo, error: unknown, json: boolean): void {
   const message = error instanceof Error ? error.message : String(error)
-  if (json) io.stdout.write(`${JSON.stringify({ type: 'error', message })}\n`)
+  if (json) io.stdout.write(`${JSON.stringify(boundJsonEvent({ type: 'error', message }))}\n`)
   io.stderr.write(`dsh: ${message}\n`)
   io.exit(1)
 }

+ 43 - 98
packages/bundle/headless/src/json-stream.ts

@@ -1,23 +1,17 @@
 /**
  * `--json` run projection: a bounded, ordered event stream derived from one
- * Agent's durable Session events plus its live Assistant frames. The stream is
- * a small vocabulary rather than a dump of the Session log, so a supervising
- * process can consume it without filtering internal events or de-duplicating
- * an assembled message against its own deltas.
+ * 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, AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 
-/** Default delay before a buffered streaming delta is flushed. */
-export const COALESCE_MS = 100
-
-/** Default buffered byte count that flushes streaming deltas immediately. */
-export const COALESCE_BYTES = 512
-
-/** Default per-string cap applied to every projected payload. */
+/** Default per-string cap applied to every bounded projected payload. */
 export const MAX_STRING_BYTES = 8 * 1024
 
 /** The stdout sink a projection writes newline-delimited events to. */
@@ -30,19 +24,15 @@ export interface JsonSink {
 export interface JsonProjectionOptions {
   /** Working directory reported by the opening `session` event. */
   cwd?: string
-  /** Coalescing delay for streaming deltas in milliseconds. */
-  coalesceMs?: number
-  /** Buffered delta bytes that flush immediately instead of waiting. */
-  coalesceBytes?: number
   /** Per-string byte cap; longer strings are truncated and flagged. */
   maxStringBytes?: number
 }
 
 /** The live handle of one `--json` projection. */
 export interface JsonProjection {
-  /** Flush buffered deltas and write the terminal `final` event. */
+  /** Write the terminal `final` event carrying the run's answer text. */
   finish(text: string): void
-  /** Stop observing; buffered deltas that were never flushed are discarded. */
+  /** Stop observing the Session. */
   dispose(): void
 }
 
@@ -74,10 +64,20 @@ function boundValue(value: unknown, maxBytes: number, state: BoundState): unknow
   return value
 }
 
-/** Bound one event payload and flag the event when any string was cut. */
-function boundEvent(event: Record<string, unknown>, maxBytes: number): Record<string, unknown> {
+/**
+ * Bound every string in one projected payload, adding `truncated: true` when
+ * any string was cut. Exported so the runner applies the same limit to its
+ * process-level `error` event.
+ * @param event - the event payload to bound.
+ * @param maxStringBytes - per-string byte cap.
+ * @returns a copy with every over-long string truncated.
+ */
+export function boundJsonEvent(
+  event: Record<string, unknown>,
+  maxStringBytes: number = MAX_STRING_BYTES,
+): Record<string, unknown> {
   const state: BoundState = { truncated: false }
-  const bounded = boundValue(event, maxBytes, state) as Record<string, unknown>
+  const bounded = boundValue(event, maxStringBytes, state) as Record<string, unknown>
   if (state.truncated) bounded.truncated = true
   return bounded
 }
@@ -104,9 +104,11 @@ function resultText(blocks: readonly { type: string; text?: string }[]): string
  * 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. Deltas are coalesced but
- * always flushed before any later event, which preserves stream order.
- * @param ctx - plugin context carrying the live Session and Assistant feeds.
+ * 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.
@@ -118,72 +120,44 @@ export function projectJsonRun(
   sink: JsonSink,
   options: JsonProjectionOptions = {},
 ): JsonProjection {
-  const coalesceMs = options.coalesceMs ?? COALESCE_MS
-  const coalesceBytes = options.coalesceBytes ?? COALESCE_BYTES
   const maxStringBytes = options.maxStringBytes ?? MAX_STRING_BYTES
   let disposed = false
-  let timer: ReturnType<typeof setTimeout> | undefined
-  let bufferedBytes = 0
-  const deltas: { kind: 'text' | 'thinking'; text: string }[] = []
   let stepUsage: SessionEvent<'assistant/message'>['data']['usage']
 
   const write = (event: Record<string, unknown>): void => {
-    if (disposed) return
-    sink.write(`${JSON.stringify(boundEvent(event, maxStringBytes))}\n`)
-  }
-
-  const flush = (): void => {
-    if (timer !== undefined) {
-      clearTimeout(timer)
-      timer = undefined
-    }
-    bufferedBytes = 0
-    for (const delta of deltas.splice(0)) write({ type: delta.kind, text: delta.text })
-  }
-
-  const emit = (event: Record<string, unknown>): void => {
-    flush()
-    write(event)
-  }
-
-  const pushDelta = (kind: 'text' | 'thinking', text: string): void => {
-    const last = deltas[deltas.length - 1]
-    if (last !== undefined && last.kind === kind) last.text += text
-    else deltas.push({ kind, text })
-    bufferedBytes += Buffer.byteLength(text, 'utf8')
-    if (bufferedBytes >= coalesceBytes) {
-      flush()
-      return
-    }
-    timer ??= setTimeout(flush, coalesceMs)
+    sink.write(`${JSON.stringify(boundJsonEvent(event, maxStringBytes))}\n`)
   }
 
   const onSessionEvent = (session: unknown, event: SessionEvent): void => {
     if (session !== agent.session) return
     switch (event.type) {
       case 'turn/start':
-        emit({ type: 'status', phase: 'turn_start', turn: event.data.turn })
+        write({ type: 'status', phase: 'turn_start', turn: event.data.turn })
         return
       case 'step/start':
-        emit({ type: 'status', phase: 'step_start', turn: event.data.turn, step: event.data.step })
+        write({ type: 'status', phase: 'step_start', turn: event.data.turn, step: event.data.step })
         return
       case 'assistant/message':
         stepUsage = event.data.usage
+        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 = stepUsage
         stepUsage = undefined
-        emit({
+        write({
           type: 'status', phase: 'step_end', turn: event.data.turn, step: event.data.step,
           ...usage === undefined ? {} : { usage },
         })
         return
       }
       case 'turn/end':
-        emit({ type: 'status', phase: 'turn_end', turn: event.data.turn, reason: event.data.reason })
+        write({ type: 'status', phase: 'turn_end', turn: event.data.turn, reason: event.data.reason })
         return
       case 'tool/call':
-        emit({
+        write({
           type: 'tool_call',
           callId: event.data.callId,
           tool: event.data.name,
@@ -191,8 +165,11 @@ export function projectJsonRun(
         })
         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]
-        emit({
+        write({
           type: 'tool_result',
           callId: block.toolCallId,
           status: block.isError === true ? 'error' : 'completed',
@@ -205,50 +182,18 @@ export function projectJsonRun(
     }
   }
 
-  const onFrame = (payload: { agent: Agent; frame: AssistantStreamFrame }): void => {
-    if (payload.agent !== agent) return
-    const frame = payload.frame
-    if (frame.type !== 'chunk') {
-      flush()
-      return
-    }
-    const chunk = frame.chunk
-    switch (chunk.type) {
-      case 'text-delta':
-        if (chunk.text !== '') pushDelta('text', chunk.text)
-        return
-      case 'reasoning-delta':
-        if (chunk.text !== '') pushDelta('thinking', chunk.text)
-        return
-      case 'block-start':
-      case 'block-end':
-      case 'tool-call-delta':
-      case 'usage':
-      case 'finish':
-        flush()
-        return
-      /* v8 ignore next -- closed-union exhaustiveness guard */
-      default:
-        return
-    }
-  }
-
   write({ type: 'session', sessionId: agent.id, cwd: options.cwd ?? process.cwd() })
   const stopSession = ctx.on('session/event', onSessionEvent)
-  const stopStream = ctx.on('agent/assistant-stream', onFrame)
 
   return {
     finish(text: string): void {
-      flush()
-      write({ type: 'final', text })
+      // 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
-      if (timer !== undefined) clearTimeout(timer)
-      timer = undefined
-      deltas.length = 0
       stopSession()
-      stopStream()
     },
   }
 }

+ 1 - 1
packages/bundle/headless/src/startup.ts

@@ -41,7 +41,7 @@ export const internals: { stdinIsTty: () => boolean } = {
 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')
     .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, or create it when absent')

+ 44 - 4
packages/bundle/headless/tests/headless.spec.ts

@@ -45,6 +45,8 @@ interface BenchOptions {
   observe?: () => Promise<ObservationStub>
   /** 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' }
 }
 
 const frameStates = new WeakMap<Agent, { attemptId: ReturnType<typeof LlmAttemptId>; revision: number; index: number }>()
@@ -175,10 +177,10 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
       const exited = new Promise<number>((resolve) => {
         ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
       })
-      if (options.prelive === true) {
+      if (options.prelive === true || options.preliveMeta !== undefined) {
         await ctx.agents.create({
           sessionId: brandString<SessionId>(options.sessionId ?? 'session-exact'),
-          meta: { cwd: process.cwd() },
+          meta: { cwd: process.cwd(), ...options.preliveMeta },
         })
       }
       apply(ctx, {
@@ -562,6 +564,41 @@ describe('headless runner', () => {
     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('belongs to a subagent')
+    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',
@@ -594,8 +631,9 @@ describe('headless runner', () => {
         session.append('step/start', { turn: 1, step: 1 })
         session.append('user/message', message, { surfaceOp: 'append' })
         startFrames(agent)
-        emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'thinking hard' })
-        emitChunk(agent, { type: 'text-delta', index: 0, text: 'answer' })
+        // 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,
@@ -603,6 +641,7 @@ describe('headless runner', () => {
           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"}' },
             ],
@@ -636,6 +675,7 @@ describe('headless runner', () => {
     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 } })

+ 142 - 151
packages/bundle/headless/tests/json-stream.spec.ts

@@ -1,13 +1,10 @@
-/** The `--json` run projection: ordering, coalescing, bounding, and disposal. */
+/** The `--json` run projection: commit-point emission, ordering, bounding, and disposal. */
 
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
 import type { Context } from '@deepseek-ai/cordis'
-import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
-import { LlmAttemptId, ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
-import { projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts'
-
-afterEach(() => { vi.useRealTimers() })
+import { boundJsonEvent, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts'
 
 interface ProjectionHarness {
   readonly lines: string[]
@@ -16,30 +13,63 @@ interface ProjectionHarness {
   readonly session: Session
   readonly parsed: () => Record<string, unknown>[]
   emitSession(event: SessionEvent): void
-  emitFrame(chunk: StreamChunk): void
   emitRawSession(session: unknown, event: SessionEvent): void
-  emitRawFrame(payload: { agent: Agent; frame: AssistantStreamFrame }): 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 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'): ProjectionHarness {
+function harness(
+  options: JsonProjectionOptions = {},
+  agentId = 'session-1',
+  cwd: string | null = '/',
+): ProjectionHarness {
   const lines: string[] = []
   const sessionListeners = new Set<(session: unknown, event: SessionEvent) => void>()
-  const frameListeners = new Set<(payload: { agent: Agent; frame: AssistantStreamFrame }) => void>()
   const ctx = {
     on(name: string, handler: unknown) {
-      const set = name === 'session/event' ? sessionListeners : frameListeners
-      set.add(handler as never)
-      return () => { set.delete(handler as never) }
+      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 },
-  }, { cwd: '/', ...options })
-  const attemptId = LlmAttemptId('attempt')
-  let revision = 0
+  }, { ...options, ...cwd === null ? {} : { cwd } })
   return {
     lines,
     projection,
@@ -47,15 +77,7 @@ function harness(options: JsonProjectionOptions = {}, agentId = 'session-1'): Pr
     session,
     parsed: () => lines.map(line => JSON.parse(line) as Record<string, unknown>),
     emitSession: (event) => { for (const listener of sessionListeners) listener(session, event) },
-    emitFrame: (chunk) => {
-      revision += 1
-      const frame: AssistantStreamFrame = {
-        type: 'chunk', attemptId, revision, index: 0, time: 0, chunk,
-      }
-      for (const listener of frameListeners) listener({ agent, frame })
-    },
     emitRawSession: (rawSession, event) => { for (const listener of sessionListeners) listener(rawSession, event) },
-    emitRawFrame: (payload) => { for (const listener of frameListeners) listener(payload) },
   }
 }
 
@@ -65,80 +87,81 @@ describe('--json projection', () => {
     expect(test.parsed()).toEqual([{ type: 'session', sessionId: 'session-1', cwd: '/' }])
   })
 
-  it('coalesces consecutive same-kind deltas and flushes them before a later event', () => {
-    const test = harness({ coalesceMs: 10_000 })
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'an' })
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'swer' })
-    test.emitFrame({ type: 'reasoning-delta', index: 0, text: 'think' })
-    test.emitFrame({ type: 'text-delta', index: 0, text: '!' })
-    test.emitSession({ type: 'tool/call', data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"a":1}' } } as unknown as SessionEvent)
-    expect(test.parsed().map(event => event.type)).toEqual(['session', 'text', 'thinking', 'text', 'tool_call'])
-    expect(test.parsed()[1]).toEqual({ type: 'text', text: 'answer' })
-    expect(test.parsed()[3]).toEqual({ type: 'text', text: '!' })
-    expect(test.parsed()[4]).toMatchObject({ type: 'tool_call', callId: 'c1', tool: 'bash', input: { a: 1 } })
+  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('flushes a buffered delta when the byte cap is reached', () => {
-    const test = harness({ coalesceMs: 10_000, coalesceBytes: 4 })
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'abc' })
-    expect(test.lines).toHaveLength(1)
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'd' })
-    expect(test.parsed().map(event => event.type)).toEqual(['session', 'text'])
-    expect(test.parsed()[1]).toEqual({ type: 'text', text: 'abcd' })
+  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('flushes a buffered delta on the coalescing timer', () => {
-    vi.useFakeTimers()
-    const test = harness({ coalesceMs: 100 })
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'later' })
-    expect(test.lines).toHaveLength(1)
-    vi.advanceTimersByTime(100)
-    expect(test.parsed().map(event => event.type)).toEqual(['session', 'text'])
+  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('bounds every long string and flags the event once', () => {
-    const test = harness({ coalesceMs: 10_000, maxStringBytes: 16 })
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'abcdefghijklmnopqrst' })
+  it('ignores a discarded attempt so retried content never reaches the stream', () => {
+    const test = harness()
     test.emitSession({
-      type: 'tool/call',
-      data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: JSON.stringify({ command: 'x'.repeat(40) }) },
+      type: 'assistant/attempt',
+      data: { turn: 1, step: 1, stream: [] },
     } as unknown as SessionEvent)
-    const events = test.parsed()
-    expect(events[1]).toEqual({ type: 'text', text: 'abcdefghijklmnop', truncated: true })
-    expect(events[2]).toMatchObject({ type: 'tool_call', truncated: true })
-    expect(events[2]?.input).toEqual({ command: 'x'.repeat(16) })
+    test.emitSession({ type: 'session/title', data: { title: 'ignored' } } as unknown as SessionEvent)
+    expect(test.parsed().map(event => event.type)).toEqual(['session'])
   })
 
-  it('ignores events from other Sessions and Agents', () => {
-    const test = harness({ coalesceMs: 10_000 })
-    test.emitRawSession({}, { type: 'turn/start', data: { turn: 1 } } as unknown as SessionEvent)
-    test.emitRawFrame({
-      agent: { id: 'other', session: {} } as unknown as Agent,
-      frame: { type: 'chunk', attemptId: LlmAttemptId('other'), revision: 1, index: 0, time: 0, chunk: { type: 'text-delta', index: 0, text: 'foreign' } },
+  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 },
     })
-    test.projection.finish('mine')
-    expect(test.parsed().map(event => event.type)).toEqual(['session', 'final'])
   })
 
   it('reports tool results as completed or errored and keeps the final event last', () => {
-    const test = harness({ coalesceMs: 10_000 })
-    test.emitSession({
-      type: 'tool/result',
-      data: {
-        turn: 1,
-        step: 1,
-        message: {
-          content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'a.txt' }], isError: false }],
-        },
-      },
-    } as unknown as SessionEvent)
+    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: 'text', text: 'boom' }], isError: true }],
+          content: [{
+            type: 'tool-result',
+            toolCallId: 'c2',
+            content: [{ type: 'image' }, { type: 'text' }, { type: 'text', text: 'boom' }],
+            isError: true,
+          }],
         },
       },
     } as unknown as SessionEvent)
@@ -149,34 +172,14 @@ describe('--json projection', () => {
     expect(events.at(-1)).toEqual({ type: 'final', text: 'done' })
   })
 
-  it('attaches step usage to the step_end status and drops buffered deltas on dispose', () => {
-    const test = harness({ coalesceMs: 10_000 })
-    test.emitSession({
-      type: 'assistant/message',
-      data: {
-        stream: [],
-        turn: 1,
-        step: 1,
-        usage: { inputTokens: 3, outputTokens: 4 },
-        message: { role: 'assistant', content: [], source: { kind: 'model', provider: 'p', model: 'm' } },
-      },
-    } as unknown as SessionEvent)
-    test.emitSession({ type: 'step/end', data: { turn: 1, step: 1 } } as unknown as SessionEvent)
-    expect(test.parsed()[1]).toEqual({ type: 'status', phase: 'step_end', turn: 1, step: 1, usage: { inputTokens: 3, outputTokens: 4 } })
-
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'dropped' })
-    test.projection.dispose()
-    test.projection.finish('ignored')
-    expect(test.parsed().map(event => event.type)).toEqual(['session', 'status'])
+  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('covers the remaining projection branches', () => {
-    const test = harness({ coalesceMs: 10_000, maxStringBytes: 11 }, 's1')
-    // An unprojected session event is ignored.
-    test.emitSession({ type: 'session/title', data: { title: 'ignored' } } as unknown as SessionEvent)
-    // A step end without a preceding assistant message carries no usage.
-    test.emitSession({ type: 'step/end', data: { turn: 1, step: 1 } } as unknown as SessionEvent)
-    // Non-JSON arguments survive as the raw string; arrays and scalars bound recursively.
+  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' },
@@ -191,53 +194,41 @@ describe('--json projection', () => {
         arguments: JSON.stringify({ items: ['éééééé', null, true], n: 1 }),
       },
     } as unknown as SessionEvent)
-    // A tool result mixes a non-text block with a text block.
-    test.emitSession({
-      type: 'tool/result',
-      data: {
-        turn: 1,
-        step: 1,
-        message: {
-          content: [{
-            type: 'tool-result',
-            toolCallId: 'nested',
-            content: [{ type: 'image' }, { type: 'text' }, { type: 'text', text: 'kept' }],
-            isError: false,
-          }],
-        },
-      },
-    } as unknown as SessionEvent)
-    // Empty deltas are dropped; every non-delta chunk flushes the buffer.
-    test.emitFrame({ type: 'text-delta', index: 0, text: '' })
-    test.emitFrame({ type: 'reasoning-delta', index: 0, text: '' })
-    test.emitFrame({ type: 'block-start', index: 0, blockType: 'text' })
-    test.emitFrame({ type: 'block-end', index: 0, block: { type: 'text', text: '' } })
-    test.emitFrame({ type: 'tool-call-delta', index: 1, id: ToolCallId('t1'), name: 'bash', argumentsDelta: '{}' })
-    test.emitFrame({ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } })
-    test.emitFrame({ type: 'finish', reason: { kind: 'stop' } })
-    // A non-chunk frame flushes and writes nothing.
-    test.emitRawFrame({
-      agent: test.agent,
-      frame: { type: 'start', attemptId: LlmAttemptId('a'), revision: 1, turn: 1, step: 1 },
+    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,
     })
-    // Truncation drops a split trailing multibyte character.
-    test.emitFrame({ type: 'text-delta', index: 0, text: 'éééééé' })
-    test.projection.finish('done')
+  })
 
-    expect(test.parsed()).toEqual([
-      { type: 'session', sessionId: 's1', cwd: '/' },
-      { type: 'status', phase: 'step_end', turn: 1, step: 1 },
-      { type: 'tool_call', callId: 'raw', tool: 'bash', input: 'not json' },
-      {
-        type: 'tool_call',
-        callId: 'nested',
-        tool: 'bash',
-        input: { items: ['ééééé', null, true], n: 1 },
-        truncated: true,
-      },
-      { type: 'tool_result', callId: 'nested', status: 'completed', result: 'kept' },
-      { type: 'text', text: 'ééééé', truncated: true },
-      { type: 'final', text: 'done' },
-    ])
+  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('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 standalone payload for the runner error event', () => {
+    expect(boundJsonEvent({ type: 'error', message: 'x'.repeat(20) }, 8))
+      .toEqual({ type: 'error', message: 'xxxxxxxx', truncated: true })
+    expect(boundJsonEvent({ type: 'error', message: 'ok' })).toEqual({ type: 'error', message: 'ok' })
   })
 })

+ 1 - 1
packages/bundle/headless/tests/startup.spec.ts

@@ -154,7 +154,7 @@ describe('headless command-line provider', () => {
   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()