Просмотр исходного кода

fix(headless): fail loud when --session-id would create a non-durable session

Address the ds-review-bot v6 review on PR #3849:

- Require the composed sessionPersistence service before creating an exact
  --session-id identity, so a profile that keeps sessionQuery but drops
  persistence exits 1 instead of printing an id whose history dies at exit.
- Stop exporting the now-internal boundJsonEvent and describe it in the
  present tense; its test now exercises boundJsonLine.
- Say "64 levels or deeper" in the README and Agent Note, matching the
  depth budget (an input keeps 63 nested containers, the 64th is cut).

Tests: 77 headless unit tests with per-file 100% coverage on src, 14 profile
e2e tests, constraints/typecheck/lint/doc/pairing gates.
lsdsjy 2 недель назад
Родитель
Сommit
b3756a89d1

+ 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: 0a243e26442d3e4546f2c6cd23d09ea2d5d19606
-2026-09-09-headless-machine-readable-run-surface.zh.md: 95001e73a6e8d3bb0406849ee82ec8da5f2f768f
+2026-09-09-headless-machine-readable-run-surface.md: a2889ee17923407d659d133938445947ae21ff17
+2026-09-09-headless-machine-readable-run-surface.zh.md: d8f2a8284fb8d078b7171ffdc26f88a475eb76c9

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

@@ -54,7 +54,7 @@ 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 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 deeper than 64 levels 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, and an empty tool-argument string projects as `{}` to match the executor. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
+- 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 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, and an empty tool-argument string projects as `{}` to match the executor. 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`, 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.
@@ -65,7 +65,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)). 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. A session linked to a parent or subagent — including a user fork — is rejected. All 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.
+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. A session linked to a parent or subagent — including a user fork — is rejected. All 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, or when creating the requested identity would lack the `sessionPersistence` service that makes it durable.
 
 ## Consequences
 

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

@@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 - 文本与推理只从已提交的 `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;空工具参数字符串会投影为 `{}`,与执行器保持一致。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
+- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,单条序列化事件行限制在 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 `type` 与 `truncated`,嵌套达到 64 层及以上的负载会在该深度被截断,因此任何合法输入都不会让限界递归溢出。进程级 `error` 事件同样受限;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter;空工具参数字符串会投影为 `{}`,与执行器保持一致。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
 - 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 `final` 结束且没有 `error` 事件,因此即使事件流格式良好,监督进程也要用退出码与 `turn_end` 原因来分类该次运行。
 - `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。
 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。
@@ -65,7 +65,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))。标识是不透明的,因此 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 始终只是创建事实。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败。
+采用时会比较持久化会话记录的 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 始终只是创建事实。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败;若创建所请求的身份时缺少让它持久化的 `sessionPersistence` 服务,同样显式失败。
 
 ## 后果
 

+ 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: 68558f272fdfb1d91220034c6dfcc8910a456195
-README.zh.md: e649422f6aa37fb2ce779768304912f8f6b71549
+README.md: 81e978b5863d1271314cb58b7bab25a383bc08da
+README.zh.md: 2125c4c5cc310059cbd00dabc4b0bb01cd33de1d

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

@@ -51,11 +51,11 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Choosing the session identity
 
-Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. 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, or that runs under an agent preset this profile does not compose — 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.
+Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise — creating requires the composed `sessionPersistence` service, so a profile that omits it fails loudly instead of returning an id whose history dies with the process. 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, or that runs under an agent preset this profile does not compose — 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 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 deeper than 64 levels is cut at that depth. An empty tool-argument string projects as `{}`, matching what the executor runs. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line. 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.
+`--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 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. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line. 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
 
@@ -142,7 +142,7 @@ These limits tell you when headless does not fit and what it needs from the `dsh
 - **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, and requires the composed Session query service.
+- **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, and requires the composed Session query and persistence services.
 - **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>

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

@@ -51,11 +51,11 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量
 
 ### 选择 Session 标识
 
-每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
+每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建——创建要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent 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 层的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。
+`--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 层及以上的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。
 
 ### 何时使用
 
@@ -142,7 +142,7 @@ runner 不向请求前缀添加任何内容;它只是把一条用户消息驱
 - **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前 stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。
 - **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。
 - **默认 stdout 只承载最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印,除非显式启用 `--json`。
-- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或运行在本 profile 不组合的 agent preset 下的 Session,并要求已组合的 Session 查询服务。
+- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或运行在本 profile 不组合的 agent preset 下的 Session,并要求已组合的 Session 查询与持久化服务。
 - **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。
 
 <a id="dev-note"></a>

+ 6 - 0
packages/bundle/headless/src/index.ts

@@ -272,6 +272,12 @@ async function resolveAgent(
   } catch (error: unknown) {
     if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
   }
+  // Creating the requested identity without a durable log would succeed, print
+  // the id, and still lose the whole history at exit — the exact continuity
+  // `--session-id` promises. A miscomposed profile fails loud instead.
+  if (ctx.get('sessionPersistence') === undefined) {
+    throw new Error('headless --session-id requires the sessionPersistence service; the created Session would not survive this process')
+  }
   const { agent } = await agents.create({
     sessionId,
     meta: { cwd: process.cwd() },

+ 2 - 3
packages/bundle/headless/src/json-stream.ts

@@ -98,13 +98,12 @@ function boundValue(value: unknown, maxBytes: number, state: BoundState, depth =
 
 /**
  * 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;
- * this form stays exported for callers that need the bounded object.
+ * 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.
  */
-export function boundJsonEvent(
+function boundJsonEvent(
   event: Record<string, unknown>,
   maxStringBytes: number = MAX_STRING_BYTES,
 ): Record<string, unknown> {

+ 18 - 0
packages/bundle/headless/tests/headless.spec.ts

@@ -44,6 +44,8 @@ interface BenchOptions {
   sessionId?: string
   json?: boolean
   observe?: () => Promise<ObservationStub>
+  /** 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. */
@@ -173,6 +175,7 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
   if (options.observe !== undefined) {
     ctx.provide('sessionQuery', { observeSession: () => options.observe!() } as never)
   }
+  if (options.omitPersistence !== true) ctx.provide('sessionPersistence', {} as never)
   return {
     ctx,
     output: () => ({ out, err, order: [...order] }),
@@ -503,6 +506,21 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
+  it('rejects creating the requested Session 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('resumes the persisted Session when the query finds it', async () => {
     const test = await bench({
       afterPrompt(session, message) { appendTurn(session, 1, message, 'resumed answer', true) },

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

@@ -4,7 +4,7 @@ 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 { boundJsonEvent, boundJsonLine, MAX_STRING_BYTES, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts'
+import { boundJsonLine, MAX_STRING_BYTES, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts'
 
 interface ProjectionHarness {
   readonly lines: string[]
@@ -298,9 +298,9 @@ describe('--json projection', () => {
     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))
+  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(boundJsonEvent({ type: 'error', message: 'ok' })).toEqual({ type: 'error', message: 'ok' })
+    expect(JSON.parse(boundJsonLine({ type: 'error', message: 'ok' }))).toEqual({ type: 'error', message: 'ok' })
   })
 })