From ba6a90d2f2087c567e26620d1fcd30d6fa048d81 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:12:32 +0800 Subject: [PATCH 01/26] feat(headless): add stdin task, --session-id, and --json run events --- ...ess-machine-readable-run-surface.i18n.yaml | 6 + ...9-headless-machine-readable-run-surface.md | 97 ++++++ ...eadless-machine-readable-run-surface.zh.md | 97 ++++++ .../headless/tests/headless.expected.e2e.ts | 30 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 12 +- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 50 ++- packages/bundle/headless/README.zh.md | 50 ++- packages/bundle/headless/cordis.patch.yml | 9 +- packages/bundle/headless/package.json | 6 +- packages/bundle/headless/src/index.ts | 173 ++++++++--- packages/bundle/headless/src/json-stream.ts | 253 ++++++++++++++++ packages/bundle/headless/src/startup.ts | 53 +++- .../bundle/headless/tests/headless.spec.ts | 286 +++++++++++++++--- .../bundle/headless/tests/json-stream.spec.ts | 172 +++++++++++ .../bundle/headless/tests/startup.spec.ts | 64 +++- packages/bundle/headless/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 20 files changed, 1231 insertions(+), 153 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md create mode 100644 packages/bundle/headless/src/json-stream.ts create mode 100644 packages/bundle/headless/tests/json-stream.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml new file mode 100644 index 0000000000..fe30c18dbf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml @@ -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: b188c0afee35b3cbb17c374f61194126d1e85be7 +2026-09-09-headless-machine-readable-run-surface.zh.md: 47cf843a612a174564f766265bb599e948b9527a diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md new file mode 100644 index 0000000000..b188c0afee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -0,0 +1,97 @@ +# 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 ` selects the exact session identity: adopt the persisted session when it exists, otherwise create it. Without the flag the run mints `session-` 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 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. No core session, persistence, session-controller, base composition, or launcher file changes. + +### Command-line contract + +```text +dsh --profile headless [--json] [--session-id ] [... | -] +``` + +Task resolution order: joined positionals, then `-`, then piped stdin. A terminal stdin with no positional task remains a usage error, so an interactive invocation cannot hang waiting for input. + +`--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` | assistant text delta | +| `thinking` | `text` | reasoning delta | +| `tool_call` | `callId`, `tool`, `input` | once per call | +| `tool_result` | `callId`, `status`, `result` | once per call | +| `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. +- `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. + +### Session identity + +The runtime owns identity. A run without `--session-id` mints `session-` and reports it in the first event. A supervisor persists that value and passes it back on the next wake. + +`--session-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. + +## Consequences + +What landed: `src/startup.ts` parses `--json` and `--session-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 or creates the exact session, and wires either the stderr reasoning projection or the new `src/json-stream.ts` projection. `cordis.patch.yml` forwards the two new settings. + +- 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 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. + +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. + +## 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 create-or-adopt 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. + +**Create-only `--session-id`.** The second wake would fail against the existing log, which is the opposite of the continuity the flag exists for. + +**Task from argv only.** Long prompts exceed `ARG_MAX` and expose the prompt in the process list. diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md new file mode 100644 index 0000000000..47cf843a61 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -0,0 +1,97 @@ +# 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 ` 选定精确的会话身份:已存在持久化会话就采用它,否则创建。不带该 flag 时,运行仍像以前一样生成 `session-`。 +- 没有位置参数、或者位置参数为 `-` 时,任务文本改从 stdin 读取。 + +每次运行的 `--model` 覆盖被明确排除在范围之外;组合默认模型仍然权威。 + +改动范围限于 `packages/bundle/headless`:`src/startup.ts`、`src/index.ts`、新增的 `src/json-stream.ts`、包清单与 `tsconfig.json`,以及测试。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。 + +### 命令行契约 + +```text +dsh --profile headless [--json] [--session-id ] [... | -] +``` + +任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。终端 stdin 且无位置参数仍是用法错误,因此交互式调用不会挂起等待输入。 + +`--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` | 轮次之外的进程级失败 | +| `final` | `text` | 最后一行 | + +投影规则: + +- 助手文本与推理增量按 100 ms 或 512 字节合并,因此流式回复保持实时,又不会每个 token 一行。 +- 增量发完之后不再重复整条助手消息。`user/message` 回显和内部会话事件(标题、模型选择、投影、检查点、目标、子 agent)都不投影。 +- 每个被投影的字符串都限制在 8 KiB;被截断的事件带 `truncated: true`。`tool_result` 不带 spill 路径,因为工具已经把自己的截断提示追加到结果文本里。 +- `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。 +- 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 + +### 会话身份 + +身份由运行时拥有。不带 `--session-id` 的运行生成 `session-`,并在第一条事件里报告它。监督进程保存该值,并在下一次唤醒时传回。 + +`--session-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` 却没有该服务时显式失败。 + +## 后果 + +实际落地:`src/startup.ts` 解析 `--json` 与 `--session-id `,把缺失或为 `-` 的任务视为"从 stdin 读取",并且只在 stdin 是终端时抛出用法错误。`src/index.ts` 解析任务、采用或创建精确会话,并接上 stderr 推理投影或新的 `src/json-stream.ts` 投影。`cordis.patch.yml` 转发这两个新设置。 + +- 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。 +- `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。 +- 两次连续的相同 `--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 期望测试端到端覆盖两种输出模式。 + +延期与未决: + +- 每次运行的 `--model` 覆盖尚未实现。后续改动必须尊重 Session Controller 拥有的会话局部选择优先级,而不是覆盖已保存的选择。 +- 冷启动加上日志重放会随会话变长而增长,因此长会话每次唤醒的代价高于新会话。 +- `--json` 把推理从 stderr 移到 stdout,因此只监听 stderr 的日志收集器在该模式的有推理运行上什么都看不到。 +- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 上限由 `src/json-stream.ts` 拥有,应保持单一常量。 + +## 备选方案 + +**`--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`。** 第二次唤醒会撞上已存在的日志,与该 flag 存在的目的正好相反。 + +**任务只走 argv。** 长提示词会超出 `ARG_MAX`,并且把提示词暴露在进程列表里。 diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 48ea951be0..48ce70655d 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -251,6 +251,36 @@ 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 with an exact 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', '--session-id', 'headless-json-session', 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', sessionId: 'headless-json-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('prints a terminal model failure through the product headless profile command', async () => { const result = await runLoaderSmoke({ label: 'product headless profile model failure snapshot', diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index bfc5221538..d99be11d7b 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 50626a7d85aa54f6d2e3cdb6140b3af8081d1461 -config-catalog.zh.md: d2ae35b91a4a83da882e8ec5af82a505aa5d5bd1 +config-catalog.md: c2f1b9a72980b78a687e407f62ef208c97042988 +config-catalog.zh.md: 311d52f570493db8e67eda46cb7e7afa2a133f6d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 50626a7d85..c2f1b9a729 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 or create; absent for a fresh random identity. */ + 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:40`](../packages/bundle/headless/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d2ae35b91a..311d52f570 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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 or create; absent for a fresh random identity. */ + 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:40`](../packages/bundle/headless/src/index.ts) diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index cae5d08de7..8b280adc63 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 98a9cb2294d8b05a40287c990b3ff53f755747cf -README.zh.md: 0f4721d856357a85964a13433c3bbc701f8fb299 +README.md: bafe5fddeca801b06e3227cd7df5ed0401e7dd25 +README.zh.md: 1cd2badf6a7ecaf4a7ce8f6ac8f4ced41af51cf5 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 98a9cb2294..bafe5fddec 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -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 works through the task 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 behind. It also offers a JSON event stream (`--json`) and a caller-chosen identity (`--session-id`). Exit code 0 means the task completed; 1 means it aborted or errored. The boundary: one task per invocation, with no interactive follow-up. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## 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: : ` 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: : ` 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. + +```sh +git diff --stat | dsh --profile headless "summarize these changes" +``` + +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-` | Exact Session identity to adopt or create | +| `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-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Adoption is scoped to the current working directory and refuses a Session owned by a subagent, so a supervisor cannot silently drive someone else's conversation; either 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. 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. + ### 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 missing or whitespace-only task is a usage error when stdin is a terminal: nothing runs and the process exits 1. When stdin is not a terminal the runner reads the task from it instead and rejects an empty result the same way. ----- @@ -61,25 +77,27 @@ The runner is a direct driver over the core API carrier: it creates one fresh Ag ### 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 creates the exact Agent identity: a fresh `session-` by default, or the id `--session-id` names, which it adopts through [`sessionQuery`](../../session-query/session-query/README.md) when a persisted log exists and creates otherwise. 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: : ` to stderr. A direct driver failure (for example, Agent creation) writes `dsh: ` 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: : ` to stderr. A direct driver failure (for example, Agent creation or an unusable `--session-id`) writes `dsh: ` 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, coalescing, 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, coalescing, 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- 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. ### Dev Note diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 0f4721d856..1cd2badf6a 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -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" ## 使用本包 -运行一个任务,获得最终答案,然后退出。任务就是命令行本身,因此整条命令就是最小的可运行示例。 +运行一个任务,获得最终答案,然后退出。任务就是命令行参数,省略时则来自 stdin;整条命令就是最小的可运行示例。 ### 运行一次性任务 @@ -33,21 +33,37 @@ kind: "package-bundle" dsh --profile headless "run the tests" ``` -agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: : ` 向 stderr 写入错误。缺失或空白任务会在任何内容运行前被拒绝。任务文本通过唯一的 `task` 设置提供: +agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: : ` 向 stderr 写入错误。任务来自位置参数,参数省略或为 `-` 时则来自 stdin;空白参数或空管道会在任何内容运行前被拒绝。 + +```sh +git diff --stat | dsh --profile headless "summarize these changes" +``` + +任务与运行选项通过三个设置提供: | 字段 | 默认值 | 含义 | |---|---|---| -| `task` | 必填 | 单次运行的任务文本 | +| `task` | stdin | 任务文本;省略或传 `-` 时由 stdin 提供 | +| `sessionId` | `session-` | 要沿用或创建的精确 Session 标识 | +| `json` | `false` | 把本次运行投影为 stdout 上的按行 JSON 事件 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)是每个受支持字段及其 JSDoc 的穷尽式真源。 +### 选择 Session 标识 + +每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。沿用被限定在当前工作目录内,并拒绝由 subagent 拥有的 Session,因此监督进程无法悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 + +### 机器可读输出 + +`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。流式的 text 与 thinking 增量在写出前会被合并,每个字符串上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败除了 stderr 的 `dsh:` 行外,还会在 stdout 上以 `error` 事件报告。 + ### 何时使用 -在脚本化或自动化的 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` 打印该命令的帮助文本并直接退出,不运行任何内容。stdin 是终端时,缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。 ----- @@ -61,25 +77,27 @@ runner 是核心 API 载体之上的直接驱动器:它通过注册表创建 ### 运行流程 -runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组合的工具与适配器不会半挂载,读取共享的 [`agentDefaultModel`](../../core/agent-default-model/README.zh.md) 选择,用该 provider 与模型创建一个全新的持久化 Agent(智能体),并把任务作为普通用户消息提交。它把该 Agent 的非空推理增量流式写入 stderr、等待完全停稳,然后 flush Session,并把所属区间(从 `firstSeq` 起)折叠为最后一条非空 `assistant/message` 文本与最终 `turn/end` 原因。最后,它把最终文本写入 stdout 并请求退出。 +runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组合的工具与适配器不会半挂载,读取共享的 [`agentDefaultModel`](../../core/agent-default-model/README.zh.md) 选择,从配置或 stdin 解析任务,然后确定精确的 Agent(智能体)标识:默认是全新的 `session-`,或 `--session-id` 指定的 id——存在持久化日志时通过 [`sessionQuery`](../../session-query/session-query/README.zh.md) 沿用,否则创建。它把任务作为普通用户消息提交。不带 `--json` 时,它把该 Agent 的非空推理增量流式写入 stderr;带 `--json` 时改为投影本次运行。它等待完全停稳,然后 flush Session,并把所属区间(从 `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: : `。直接驱动器失败(例如 Agent 创建失败)向 stderr 写入 `dsh: ` 并退出 1。 +最终 `turn/end` 完成时退出码为 0;任何其他结果——aborted、error,或所属区间内没有轮次——退出码为 1。结束原因为 `error` 时还会向 stderr 写入 `dsh: : `。直接驱动器失败(例如 Agent 创建失败或不可用的 `--session-id`)向 stderr 写入 `dsh: ` 并退出 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 | | — | 不发布运行时不变式伴生入口;可观察的行为属于进程级组合,本包只持有静态 patch 列表。 | -| [`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 与归属限制**——`--session-id` 会拒绝记录在其他工作目录或由 subagent 拥有的 Session,并要求已组合的 Session 查询服务。 +- **事件流是投影而非日志**——`--json` 会合并增量并把字符串限制在 8 KiB,因此它不是 Session 日志的无损副本。 ### 开发备注 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index f20194e862..c7b514729d 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,8 +1,9 @@ # 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 ""`) and this app's --help, then the direct -# driver creates an Agent through the core registry and prints its durable result. +# (`dsh --profile headless ""`), the `--session-id` and `--json` options, +# and this app's --help, then the direct driver creates or adopts an Agent +# through the core registry and prints its durable result. - id: system-prompt config: @@ -23,9 +24,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 diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index b8f809ebf3..bec09e0e60 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -52,7 +52,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 +63,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:^" } } diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 50a5c664be..7d36e8e502 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -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,14 @@ 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 { projectJsonRun } from './json-stream.ts' /** Stable Cordis plugin name. */ export const name = 'headless-runner' @@ -30,14 +36,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 or create; absent for a fresh random identity. */ + sessionId?: string + /** Whether stdout carries the machine-readable event stream instead of final text. */ + json?: boolean } export const Config: z = z.object({ - task: z.string().required(), + task: z.string(), + sessionId: z.string(), + json: z.boolean(), }) /** Outcome of one owned run interval. */ @@ -54,10 +66,19 @@ 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'] } = { +/** The process streams the runner reads and writes; tests substitute captures. */ +export const internals: { + stdout: HeadlessIo['stdout'] + stderr: HeadlessIo['stderr'] + readStdin: () => Promise +} = { stdout: process.stdout, stderr: process.stderr, + readStdin: async () => { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin as AsyncIterable) chunks.push(chunk) + return Buffer.concat(chunks).toString('utf8') + }, } /** Aggregate the last assistant text and turn outcome in one owned interval. */ @@ -154,19 +175,67 @@ function streamReasoning( } } +/** + * 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. + * @param ctx - plugin context carrying the Session query service. + * @param agents - the core Agent registry. + * @param sessionId - exact Session identity to adopt or create. + * @param agentOptions - provider/model pair for this run. + * @param setup - per-Agent scope setup installing the model selection. + * @returns the live, resumed, or freshly created Agent. + */ +async function resolveAgent( + ctx: Context, + agents: Context['agents'], + sessionId: SessionId, + agentOptions: { provider: string; model: string }, + setup: (agentCtx: Context) => void, +): Promise { + const live = agents.get(sessionId) + if (live !== undefined) 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()}"`) + } + const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup }) + return agent + } catch (error: unknown) { + if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + } + const { agent } = await agents.create({ + sessionId, + meta: { cwd: process.cwd() }, + agentOptions, + setup, + }) + return agent +} + /** 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(`${JSON.stringify({ 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 { +async function run(ctx: Context, config: Config, io: HeadlessIo): Promise { // 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() @@ -176,45 +245,59 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { // Early process shutdown can dispose the tree while settlement is pending. if (agents === undefined || defaultModel === undefined || sessions === undefined) return + 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() - // 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(`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 agentOptions = { provider: selection.provider, model: selection.model } + const setup = (agentCtx: Context): void => { + const selected: ModelSelectionRef = { current: selection, assembled: undefined } + installModelSelection(agentCtx, selected) + } + const sessionId = brandString(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() 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 @@ -224,5 +307,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) }) } diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts new file mode 100644 index 0000000000..c51d624025 --- /dev/null +++ b/packages/bundle/headless/src/json-stream.ts @@ -0,0 +1,253 @@ +/** + * `--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. + * @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 { 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. */ +export const MAX_STRING_BYTES = 8 * 1024 + +/** 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 + /** 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. */ + finish(text: string): void + /** Stop observing; buffered deltas that were never flushed are discarded. */ + 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 +} + +/** Recursively cap every string in one JSON-serializable value. */ +function boundValue(value: unknown, maxBytes: number, state: BoundState): unknown { + if (typeof value === 'string') { + if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value + state.truncated = true + return truncateUtf8(value, maxBytes) + } + if (Array.isArray(value)) return value.map(item => boundValue(item, maxBytes, state)) + if (value !== null && typeof value === 'object') { + const bounded: Record = {} + for (const [key, item] of Object.entries(value)) bounded[key] = boundValue(item, maxBytes, state) + return bounded + } + return value +} + +/** Bound one event payload and flag the event when any string was cut. */ +function boundEvent(event: Record, maxBytes: number): Record { + const state: BoundState = { truncated: false } + const bounded = boundValue(event, maxBytes, state) as Record + if (state.truncated) bounded.truncated = true + return bounded +} + +/** Parse raw tool-call arguments, keeping the unparsed string when it is not JSON. */ +function parseArguments(raw: string): unknown { + try { + return JSON.parse(raw) as unknown + } 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.type === 'text' && typeof block.text === 'string') + .map(block => block.text ?? '') + .join('') +} + +/** + * 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. + * @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 coalesceMs = options.coalesceMs ?? COALESCE_MS + const coalesceBytes = options.coalesceBytes ?? COALESCE_BYTES + const maxStringBytes = options.maxStringBytes ?? MAX_STRING_BYTES + let disposed = false + let timer: ReturnType | undefined + let bufferedBytes = 0 + const deltas: { kind: 'text' | 'thinking'; text: string }[] = [] + let stepUsage: SessionEvent<'assistant/message'>['data']['usage'] + + const write = (event: Record): 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): 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) + } + + 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 }) + return + case 'step/start': + emit({ type: 'status', phase: 'step_start', turn: event.data.turn, step: event.data.step }) + return + case 'assistant/message': + stepUsage = event.data.usage + return + case 'step/end': { + const usage = stepUsage + stepUsage = undefined + emit({ + 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 }) + return + case 'tool/call': + emit({ + type: 'tool_call', + callId: event.data.callId, + tool: event.data.name, + input: parseArguments(event.data.arguments), + }) + return + case 'tool/result': { + const block = event.data.message.content[0] + emit({ + type: 'tool_result', + callId: block.toolCallId, + status: block.isError === true ? 'error' : 'completed', + result: resultText(block.content), + }) + return + } + default: + return + } + } + + 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 }) + }, + dispose(): void { + disposed = true + if (timer !== undefined) clearTimeout(timer) + timer = undefined + deltas.length = 0 + stopSession() + stopStream() + }, + } +} diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index f1bc01125d..42d07e8720 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -1,7 +1,8 @@ /** - * 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 */ @@ -20,12 +21,21 @@ 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 or create; absent for a fresh random identity. */ + sessionId: string | undefined + /** Whether stdout carries the machine-readable event stream instead of final text. */ + json: boolean +} + +/** Process facts the provider reads; tests substitute them. */ +export const internals: { stdinIsTty: () => boolean } = { + stdinIsTty: () => process.stdin.isTTY, } /** - * 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 { @@ -33,25 +43,42 @@ function headlessCommand(): Command { .name('dsh --profile headless') .description('Answer one task, stream reasoning to stderr, print the final assistant message, and exit.') .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 ', 'adopt the persisted Session with this id, or create it when absent') + .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" adopt a Session `) } /** * 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() 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) + const joined = program.args.join(' ') + const task = joined.trim() === '' ? 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 }>() + const sessionId = options.sessionId?.trim() + if (options.sessionId !== undefined && sessionId === '') { + 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) } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 6f583fad95..d6aceaffc3 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -1,14 +1,22 @@ -/** 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 { 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' @@ -20,6 +28,22 @@ interface Script { afterPrompt(session: Session, message: UserMessage, agent: Agent): Promise | void } +/** Observation stub returned by the `--session-id` query path. */ +interface ObservationStub { + header: { cwd: string; origin?: string; parentSession?: string } + [Symbol.dispose](): void +} + +/** Runner invocation options layered over the scripted Agent factory. */ +interface BenchOptions { + task?: string + useStdin?: boolean + readStdin?: () => Promise + sessionId?: string + json?: boolean + observe?: () => Promise +} + const frameStates = new WeakMap; revision: number; index: number }>() function startFrames(agent: Agent, turn = 1, step = 1): void { @@ -74,7 +98,7 @@ function appendTurn( } /** 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 +107,60 @@ async function bench(script: Script): Promise<{ let out = '' let err = '' const order: string[] = [] + + const mount = async ( + ownerCtx: Context, + session: Session, + createOptions: CreateAgentOptions | ResumeAgentOptions, + ): Promise => { + 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: () => 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 { - const session = ctx.sessions.create(options.sessionId, { - ...options.meta === undefined ? {} : { meta: options.meta }, + async createAgent(ownerCtx: Context, createOptions: CreateAgentOptions): Promise { + 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 { + 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.observe !== undefined) { + ctx.provide('sessionQuery', { observeSession: () => options.observe!() } as never) + } return { ctx, output: () => ({ out, err, order: [...order] }), @@ -126,10 +168,15 @@ 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((resolve) => { ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) }) }) - apply(ctx, { task: 'do the thing' }) + 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 +422,174 @@ 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('creates the exact requested Session when the query reports it 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')), + }) + expect(await test.run()).toMatchObject({ code: 0, out: 'created\n', err: '' }) + expect(seen).toEqual(['session-exact']) + 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' }, + [Symbol.dispose]() {}, + }), + }) + const session = test.ctx.sessions.create(brandString('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' }, + [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 owned by a subagent', async () => { + const test = await bench({ afterPrompt: () => {} }, { + sessionId: 'session-exact', + observe: () => Promise.resolve({ + header: { cwd: process.cwd(), origin: 'subagent' }, + [Symbol.dispose]() {}, + }), + }) + const result = await test.run() + expect(result.code).toBe(1) + expect(result.err).toContain('belongs to a subagent') + 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' }) + const result = await test.run() + expect(result.code).toBe(1) + expect(result.err).toContain('requires the sessionQuery service') + 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) + emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'thinking hard' }) + emitChunk(agent, { type: 'text-delta', index: 0, text: 'answer' }) + session.append('assistant/message', { + stream: [], + turn: 1, + step: 1, + usage: { inputTokens: 3, outputTokens: 4 }, + message: createAssistantMessage({ + content: [ + { 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) + 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(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 +657,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 }) }) }) diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts new file mode 100644 index 0000000000..b622cab021 --- /dev/null +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -0,0 +1,172 @@ +/** The `--json` run projection: ordering, coalescing, bounding, and disposal. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import { LlmAttemptId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts' + +afterEach(() => { vi.useRealTimers() }) + +interface ProjectionHarness { + readonly lines: string[] + readonly projection: ReturnType + readonly agent: Agent + readonly session: Session + readonly parsed: () => Record[] + emitSession(event: SessionEvent): void + emitFrame(chunk: StreamChunk): void + emitRawSession(session: unknown, event: SessionEvent): void + emitRawFrame(payload: { agent: Agent; frame: AssistantStreamFrame }): void +} + +/** Drive the projector through a minimal Context and Agent double. */ +function harness(options: JsonProjectionOptions = {}): 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) } + }, + } as unknown as Context + const session = {} as Session + const agent = { id: 'session-1', 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 + return { + lines, + projection, + agent, + session, + parsed: () => lines.map(line => JSON.parse(line) as Record), + 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) }, + } +} + +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('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('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('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('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' }) + test.emitSession({ + type: 'tool/call', + data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: JSON.stringify({ command: 'x'.repeat(40) }) }, + } 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) }) + }) + + 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' } }, + }) + 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) + test.emitSession({ + type: 'tool/result', + data: { + turn: 1, + step: 1, + message: { + content: [{ type: 'tool-result', toolCallId: 'c2', content: [{ 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('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']) + }) +}) diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 29c5a7666b..0f05280f8b 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -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,9 +11,14 @@ 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, + internals as startupInternals, + type HeadlessStartupValues, +} from '../src/startup.ts' /** What one boot of the fixture tree observed. */ interface Observed { @@ -30,16 +35,21 @@ 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 }) /** * 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: '' } @@ -58,13 +68,16 @@ 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 + cmdlineInternals.stdout = observing + cmdlineInternals.stderr = observing + startupInternals.stdinIsTty = () => options.stdinIsTty === true const globals = globalThis as unknown as { __headlessStartupApply: typeof apply __headlessStartupObserved: Observed @@ -88,23 +101,48 @@ 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('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('--session-id') expect(task).toBeUndefined() expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 0c60d69aa7..b86afc4103 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../boot/cmdline" + }, + { + "path": "../../session-query/session-query" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03486dc393..af4a4ee53d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1631,6 +1631,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: From d5fe1fb887441fe0a16fc0c79f72ab7a722ed8b8 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:17:01 +0800 Subject: [PATCH 02/26] docs(headless): regenerate event producer graph and restore model-selection note --- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- packages/bundle/headless/src/index.ts | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b1f10816f3..9721a9e9dc 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: 449b7d8f0fb55515e7f1f028e151ce1b26862e92 -event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2 +event-producer-consumer.md: 03e23b606d8420735d3409b0201b2f7379597212 +event-producer-consumer.zh.md: a2e103f7c53d1f24ce71937e0c52b0fa8b2c41c4 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 449b7d8f0f..03e23b606d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8120df6de6..a2e103f7c5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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) | diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 7d36e8e502..768b8adce8 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -254,6 +254,10 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise 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 setup = (agentCtx: Context): void => { const selected: ModelSelectionRef = { current: selection, assembled: undefined } installModelSelection(agentCtx, selected) From 0e7361a4890238756ccb312a1d0285baeaeaa954 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:19:52 +0800 Subject: [PATCH 03/26] test(headless): close per-file coverage on the run projection --- packages/bundle/headless/src/json-stream.ts | 5 +- .../bundle/headless/tests/headless.spec.ts | 69 +++++++++++++++++ .../bundle/headless/tests/json-stream.spec.ts | 77 ++++++++++++++++++- .../bundle/headless/tests/startup.spec.ts | 13 ++++ 4 files changed, 159 insertions(+), 5 deletions(-) diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index c51d624025..c9422fbf81 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -94,8 +94,9 @@ function parseArguments(raw: string): unknown { /** 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.type === 'text' && typeof block.text === 'string') - .map(block => block.text ?? '') + .filter((block): block is { type: string; text: string } => + block.type === 'text' && typeof block.text === 'string') + .map(block => block.text) .join('') } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index d6aceaffc3..b4d658eae1 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -1,5 +1,6 @@ /** 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' @@ -42,6 +43,8 @@ interface BenchOptions { sessionId?: string json?: boolean observe?: () => Promise + /** Register a live Agent under `sessionId` before the runner starts. */ + prelive?: boolean } const frameStates = new WeakMap; revision: number; index: number }>() @@ -172,6 +175,12 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ const exited = new Promise((resolve) => { ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) }) }) + if (options.prelive === true) { + await ctx.agents.create({ + sessionId: brandString(options.sessionId ?? 'session-exact'), + meta: { cwd: process.cwd() }, + }) + } apply(ctx, { ...options.useStdin === true ? {} : { task: options.task ?? 'do the thing' }, ...options.sessionId === undefined ? {} : { sessionId: options.sessionId }, @@ -445,6 +454,30 @@ describe('headless runner', () => { 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('creates the exact requested Session when the query reports it missing', async () => { const seen: string[] = [] const test = await bench({ @@ -518,6 +551,42 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('reuses a live Agent already registered under the requested identity', async () => { + const test = await bench({ + afterPrompt(session, message) { appendTurn(session, 1, message, 'live answer', true) }, + }, { + sessionId: 'session-exact', + prelive: true, + }) + expect(await test.run()).toMatchObject({ code: 0, out: 'live answer\n', err: '' }) + 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' }, + [Symbol.dispose]() {}, + }), + }) + const result = await test.run() + expect(result.code).toBe(1) + expect(result.err).toContain('belongs to a subagent') + 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) { diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index b622cab021..31425299ce 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent' -import { LlmAttemptId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAttemptId, ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts' @@ -22,7 +22,7 @@ interface ProjectionHarness { } /** Drive the projector through a minimal Context and Agent double. */ -function harness(options: JsonProjectionOptions = {}): ProjectionHarness { +function harness(options: JsonProjectionOptions = {}, agentId = 'session-1'): ProjectionHarness { const lines: string[] = [] const sessionListeners = new Set<(session: unknown, event: SessionEvent) => void>() const frameListeners = new Set<(payload: { agent: Agent; frame: AssistantStreamFrame }) => void>() @@ -34,7 +34,7 @@ function harness(options: JsonProjectionOptions = {}): ProjectionHarness { }, } as unknown as Context const session = {} as Session - const agent = { id: 'session-1', session } as unknown as Agent + const agent = { id: agentId, session } as unknown as Agent const projection = projectJsonRun(ctx, agent, { write: (chunk: string) => { lines.push(chunk); return true }, }, { cwd: '/', ...options }) @@ -169,4 +169,75 @@ describe('--json projection', () => { test.projection.finish('ignored') expect(test.parsed().map(event => event.type)).toEqual(['session', 'status']) }) + + 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. + 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) + // 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 }, + }) + // 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' }, + ]) + }) }) diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 0f05280f8b..9dcfdfe2df 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -29,6 +29,9 @@ interface Observed { const disposers: (() => Promise)[] = [] +/** 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[] = [] @@ -138,6 +141,16 @@ describe('headless command-line provider', () => { 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('prints its own help and leaves the runner pending', async () => { const { task, observed } = await bootStartup(['--help']) expect(observed.out).toContain('dsh --profile headless') From 8662afb530c7195f5b8b2a3761854f76eb9aa634 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:31:24 +0800 Subject: [PATCH 04/26] docs(headless): regenerate module graph for the run projection --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 11 ++++++----- docs/module-graph.zh.md | 11 ++++++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index cf4f2a6735..f2fb06aa19 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: e498227db7cc482beb2dd69aab147baec8eead22 -module-graph.zh.md: c3956f8b466f791b2b90aee12bedba7f95ff7cb9 +module-graph.md: ca5bfb74a0fc1edf9fa48634df4c5a81ac567052 +module-graph.zh.md: d4d0fa446fd15573f40d582776e70d8082d4af1c diff --git a/docs/module-graph.md b/docs/module-graph.md index e498227db7..ca5bfb74a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -656,10 +656,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 @@ -1003,6 +999,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 @@ -1362,7 +1363,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) | | [`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) | @@ -1422,6 +1422,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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c3956f8b46..d4d0fa446f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -658,10 +658,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 @@ -1005,6 +1001,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 @@ -1364,7 +1365,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) | | [`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) | @@ -1424,6 +1424,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) | From ba6e19809dd9e21ec08dafdd8dc6ae560c508f3c Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:50:13 +0800 Subject: [PATCH 05/26] fix(headless): project committed content and scope live adoption --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +- ...9-headless-machine-readable-run-surface.md | 20 +- ...eadless-machine-readable-run-surface.zh.md | 20 +- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 12 +- packages/bundle/headless/README.zh.md | 12 +- packages/bundle/headless/src/index.ts | 35 +- packages/bundle/headless/src/json-stream.ts | 141 +++----- packages/bundle/headless/src/startup.ts | 2 +- .../bundle/headless/tests/headless.spec.ts | 48 ++- .../bundle/headless/tests/json-stream.spec.ts | 301 +++++++++--------- .../bundle/headless/tests/startup.spec.ts | 2 +- 12 files changed, 298 insertions(+), 303 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index fe30c18dbf..7f3c91d168 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index b188c0afee..c55f0b9631 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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-` a `--session-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 `, 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 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 47cf843a61..7826fe0435 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -42,18 +42,19 @@ dsh --profile headless [--json] [--session-id ] [... | -] |---|---|---| | `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 ] [... | -] `--session-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 ] [... | -] - 默认模式不变:纯文本运行向 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 ] [... | -] - 每次运行的 `--model` 覆盖尚未实现。后续改动必须尊重 Session Controller 拥有的会话局部选择优先级,而不是覆盖已保存的选择。 - 冷启动加上日志重放会随会话变长而增长,因此长会话每次唤醒的代价高于新会话。 - `--json` 把推理从 stderr 移到 stdout,因此只监听 stderr 的日志收集器在该模式的有推理运行上什么都看不到。 -- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 上限由 `src/json-stream.ts` 拥有,应保持单一常量。 +- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 上限由 `src/json-stream.ts` 拥有,应保持单一常量,终止 `final` 事件是唯一的例外。 ## 备选方案 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 8b280adc63..de7953c065 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index bafe5fddec..484d975135 100644 --- a/packages/bundle/headless/README.md +++ b/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: : ` 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: : ` 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-` 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. ### Dev Note diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 1cd2badf6a..1206e79392 100644 --- a/packages/bundle/headless/README.zh.md +++ b/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: : ` 向 stderr 写入错误。任务来自位置参数,参数省略或为 `-` 时则来自 stdin;空白参数或空管道会在任何内容运行前被拒绝。 +agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: : ` 向 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 日志的无损副本。 ### 开发备注 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 768b8adce8..99eb8d5752 100644 --- a/packages/bundle/headless/src/index.ts +++ b/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 { 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) } diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index c9422fbf81..dc2e5f3c6e 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/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, maxBytes: number): Record { +/** + * 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, + maxStringBytes: number = MAX_STRING_BYTES, +): Record { const state: BoundState = { truncated: false } - const bounded = boundValue(event, maxBytes, state) as Record + const bounded = boundValue(event, maxStringBytes, state) as Record 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 | undefined - let bufferedBytes = 0 - const deltas: { kind: 'text' | 'thinking'; text: string }[] = [] let stepUsage: SessionEvent<'assistant/message'>['data']['usage'] const write = (event: Record): 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): 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() }, } } diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 42d07e8720..237d9d4ae5 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/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 ', 'adopt the persisted Session with this id, or create it when absent') diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index b4d658eae1..d2ad0a24e2 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -45,6 +45,8 @@ interface BenchOptions { observe?: () => Promise /** 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; revision: number; index: number }>() @@ -175,10 +177,10 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ const exited = new Promise((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(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 } }) diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 31425299ce..9990ec3a50 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/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[] 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), 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('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('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('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: 'tool/call', - data: { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: JSON.stringify({ command: 'x'.repeat(40) }) }, + type: 'turn/end', + data: { turn: 1, reason: { kind: 'completed' } }, } 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) }) + 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('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('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 }, }) - 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 }) + 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: 'c1', content: [{ type: 'text', text: 'a.txt' }], isError: false }], - }, - }, - } as unknown as SessionEvent) - test.emitSession({ - type: 'tool/result', - 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' }) }) }) diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 9dcfdfe2df..cc4ee31334 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/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() From 45032ea1bdaad57f5ceb4360beff5a405217592f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:58:24 +0800 Subject: [PATCH 06/26] fix(headless): address re-review findings on the machine-readable run surface - Reject adopting a Session recorded under an agent preset, and one that recorded no cwd; align the subagent/forked diagnostic wording. - Write the bounded JSON `error` event for a --json usage error before exit, since the runner never mounts to write it. - Keep the caller's exact --session-id instead of the trimmed value. - Bound object keys and copy a literal `__proto__` key as data rather than through the inherited setter. - Reject a lone `-` mixed with other task words. - Document the in-turn failure contract and step-commit text/thinking timing. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +- ...9-headless-machine-readable-run-surface.md | 11 +++-- ...eadless-machine-readable-run-surface.zh.md | 11 +++-- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 8 +-- packages/bundle/headless/README.zh.md | 10 ++-- packages/bundle/headless/src/index.ts | 15 +++++- packages/bundle/headless/src/json-stream.ts | 31 ++++++++---- packages/bundle/headless/src/startup.ts | 32 +++++++++--- .../bundle/headless/tests/headless.spec.ts | 49 +++++++++++++++++-- .../bundle/headless/tests/json-stream.spec.ts | 32 ++++++++++++ .../bundle/headless/tests/startup.spec.ts | 33 +++++++++++++ 12 files changed, 195 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 7f3c91d168..7dc3618f54 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: c55f0b96313cfe792e845396b7049249f4ca43ee -2026-09-09-headless-machine-readable-run-surface.zh.md: 7826fe0435acba74e7156bdd2a3abd9b0372cf7f +2026-09-09-headless-machine-readable-run-surface.md: f4fd89b3ef0ae8528574517bb740396bfadec981 +2026-09-09-headless-machine-readable-run-surface.zh.md: 25a41b7fa3d2d46b80e01ca33809e9436db66212 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index c55f0b9631..f4fd89b3ef 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -30,7 +30,7 @@ The change is confined to `packages/bundle/headless`: `src/startup.ts`, `src/ind dsh --profile headless [--json] [--session-id ] [... | -] ``` -Task resolution order: joined positionals, then `-`, then piped stdin. A terminal stdin with no positional task remains a usage error, so an interactive invocation cannot hang waiting for input. +Task resolution order: joined positionals, then `-`, then piped stdin. A terminal stdin with no positional task remains a usage error, so an interactive invocation cannot hang waiting for input. 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. In `--json` mode a usage error writes the `error` event before the process exits, because the runner never mounts to write it. `--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. @@ -54,7 +54,8 @@ 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 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. +- Every projected string and object key is bounded at 8 KiB, and an event with a cut value carries `truncated: true`, including the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter. 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. @@ -62,9 +63,9 @@ Projection rules: The runtime owns identity. A run without `--session-id` mints `session-` and reports it in the first event. A supervisor persists that value and passes it back on the next wake. -`--session-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)). +`--session-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. 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. +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 created 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 was recorded under. 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. ## Consequences @@ -73,7 +74,7 @@ What landed: `src/startup.ts` parses `--json` and `--session-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. - 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. +- Two consecutive runs with the same `--session-id` share history. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, or that was created under an agent preset, 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. diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 7826fe0435..25a41b7fa3 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -30,7 +30,7 @@ Status: implemented dsh --profile headless [--json] [--session-id ] [... | -] ``` -任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。终端 stdin 且无位置参数仍是用法错误,因此交互式调用不会挂起等待输入。 +任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。终端 stdin 且无位置参数仍是用法错误,因此交互式调用不会挂起等待输入。单独的 `-` 是唯一的 stdin 标记:把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,用法错误会在进程退出前写出 `error` 事件,因为 runner 从未挂载来写它。 `--json` 只改变 stdout 负载和推理投影的去向。退出码、关闭顺序、会话 flush 和持久化会话日志都不变,因此监督进程对一次运行的分类方式与现在完全一致。 @@ -54,7 +54,8 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 文本与推理只从已提交的 `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` 事件刻意不做限长:它承载与默认模式相同的无损答案。 +- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,进程级 `error` 事件同样如此;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。 +- 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 `final` 结束且没有 `error` 事件,因此即使事件流格式良好,监督进程也要用退出码与 `turn_end` 原因来分类该次运行。 - `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 @@ -62,9 +63,9 @@ dsh --profile headless [--json] [--session-id ] [... | -] 身份由运行时拥有。不带 `--session-id` 的运行生成 `session-`,并在第一条事件里报告它。监督进程保存该值,并在下一次唤醒时传回。 -`--session-id ` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。 +`--session-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,而不是静默续接一个根目录在别处的会话。带父会话或子 agent 关联的会话被拒绝。当某个存活 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 的工具与提示词运行它,而不是它日志记录时所用的组合。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败。 ## 后果 @@ -73,7 +74,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。 - `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。 - 发生重试的步骤只为最终提交的 attempt 发布 `text` 与 `thinking`,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。 -- 两次连续的相同 `--session-id` 运行共享历史。cwd 与持久化会话不一致的运行以诊断退出 1,无论身份是存活还是持久化的。 +- 两次连续的相同 `--session-id` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话,或由 agent preset 创建的运行都以诊断退出 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 期望测试端到端覆盖两种输出模式。 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index de7953c065..a38fc3c596 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 484d975135dcf494fb05bdc41b5413596b57b40f -README.zh.md: 1206e793923826810f5cc9cc812cb47fbc979130 +README.md: b79433c3a5b95224b1c18aec65cb4eafa1e5dc15 +README.zh.md: a822106be6e1dcc972c048fa76c0a22694d4682d diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 484d975135..b79433c3a5 100644 --- a/packages/bundle/headless/README.md +++ b/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-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Adoption is scoped to the current working directory and refuses a Session owned by a subagent, so a supervisor cannot silently drive someone else's conversation; either mismatch fails before the task runs. +Every invocation defaults to a fresh `session-` identity. Pass `--session-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 was created under an agent preset this profile does not compose, so a supervisor 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. 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. +`--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`. 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 @@ -63,7 +63,7 @@ Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick ### 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 when stdin is a terminal: nothing runs and the process exits 1. When stdin is not a terminal the runner reads the task from it instead and rejects an empty result the same way. +`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 when stdin is a terminal: nothing runs and the process exits 1. When stdin is not a terminal the runner reads the task from it instead 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 a usage error 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. ----- @@ -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- 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. +- **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 created under an agent preset this profile does not compose, and requires the composed Session query service. - **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. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 1206e79392..a822106be6 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -51,11 +51,11 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。沿用被限定在当前工作目录内,并拒绝由 subagent 拥有的 Session,因此监督进程无法悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及由本 profile 不组合的 agent preset 创建的会话,因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 ### 机器可读输出 -`--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:` 行。 +`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流;它们在步骤提交时到达,而不是逐 token 到达,默认模式的 stderr 推理仍是唯一的实时文本通道。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串与对象键上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。 ### 何时使用 @@ -63,7 +63,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 帮助与任务错误 -`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。stdin 是终端时,缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。 +`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。stdin 是终端时,缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。单独的 `-` 是唯一的 stdin 标记;把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,用法错误还会在进程退出前向 stdout 写出 `error` 事件,因此按行解析的监督进程即使 runner 从未挂载也能看到格式良好的事件流。 ----- @@ -142,8 +142,8 @@ runner 不向请求前缀添加任何内容;它只是把一条用户消息驱 - **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前 stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。 - **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。 - **默认 stdout 只承载最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印,除非显式启用 `--json`。 -- **沿用受 cwd 与归属限制**——`--session-id` 会拒绝记录在其他工作目录或由 subagent 拥有的 Session,并要求已组合的 Session 查询服务。 -- **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。 +- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或由本 profile 不组合的 agent preset 创建的 Session,并要求已组合的 Session 查询服务。 +- **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。 ### 开发备注 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 99eb8d5752..9d64057b89 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -180,12 +180,25 @@ interface AdoptableHeader { cwd?: string | undefined origin?: 'subagent' | undefined parentSession?: SessionId | undefined + agentPreset?: string | undefined } /** Reject a Session the one-shot runner must not adopt. */ function assertAdoptable(header: AdoptableHeader, sessionId: SessionId): void { + if (header.agentPreset !== 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 the log was recorded under. + throw new Error( + `session "${sessionId}" was created under agent preset "${header.agentPreset}", ` + + 'which the one-shot runner does not compose', + ) + } if (header.origin === 'subagent' || header.parentSession !== undefined) { - throw new Error(`session "${sessionId}" belongs to a subagent and cannot be driven directly`) + 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()}"`) diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index dc2e5f3c6e..009e96feb4 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionEvent } from '@deepseek-ai/dsh-session' -/** Default per-string cap applied to every bounded projected payload. */ +/** Default per-string and per-key cap applied to every bounded projected payload. */ export const MAX_STRING_BYTES = 8 * 1024 /** The stdout sink a projection writes newline-delimited events to. */ @@ -24,7 +24,7 @@ export interface JsonSink { export interface JsonProjectionOptions { /** Working directory reported by the opening `session` event. */ cwd?: string - /** Per-string byte cap; longer strings are truncated and flagged. */ + /** Per-string and per-key byte cap; longer values are truncated and flagged. */ maxStringBytes?: number } @@ -48,7 +48,14 @@ function truncateUtf8(text: string, maxBytes: number): string { return decoded.endsWith('\uFFFD') ? decoded.slice(0, -1) : decoded } -/** Recursively cap every string in one JSON-serializable value. */ +/** 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) +} + +/** Recursively cap every string in one JSON-serializable value, keys included. */ function boundValue(value: unknown, maxBytes: number, state: BoundState): unknown { if (typeof value === 'string') { if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value @@ -57,20 +64,26 @@ function boundValue(value: unknown, maxBytes: number, state: BoundState): unknow } if (Array.isArray(value)) return value.map(item => boundValue(item, maxBytes, state)) if (value !== null && typeof value === 'object') { - const bounded: Record = {} - for (const [key, item] of Object.entries(value)) bounded[key] = boundValue(item, maxBytes, state) + // 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 + for (const [key, item] of Object.entries(value)) { + bounded[boundKey(key, maxBytes, state)] = boundValue(item, maxBytes, state) + } return bounded } return value } /** - * 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 + * Bound every string and key in one projected payload, adding `truncated: true` + * when any 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. + * @param maxStringBytes - per-string and per-key byte cap. + * @returns a copy with every over-long string and key truncated. */ export function boundJsonEvent( event: Record, diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 237d9d4ae5..950ee70993 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -9,6 +9,7 @@ import { Command } from 'commander' import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' +import { boundJsonEvent } from './json-stream.ts' /** Stable Cordis plugin name. */ export const name = 'headless-startup' @@ -30,8 +31,12 @@ export interface HeadlessStartupValues { } /** Process facts the provider reads; tests substitute them. */ -export const internals: { stdinIsTty: () => boolean } = { +export const internals: { + stdinIsTty: () => boolean + stdout: { write(chunk: string): unknown } +} = { stdinIsTty: () => process.stdin.isTTY, + stdout: process.stdout, } /** @@ -64,20 +69,33 @@ Examples: export function apply(ctx: Context): void { const program = headlessCommand() program.action(() => { + const options = program.opts<{ json?: boolean; sessionId?: string }>() + const json = options.json === true + // A usage error is still a process-level failure outside a turn, so a + // --json caller is owed the documented error event even though the runner + // never mounts to write it. + const reject = (message: string): never => { + if (json) internals.stdout.write(`${JSON.stringify(boundJsonEvent({ type: 'error', message }))}\n`) + return program.error(message) + } + if (program.args.length > 1 && program.args.includes('-')) { + reject('error: `-` must be the only task argument') + } const joined = program.args.join(' ') const task = joined.trim() === '' ? undefined : joined if (task === undefined && internals.stdinIsTty()) { - program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + reject('error: a task is required, for example: dsh --profile headless "run the tests"') } - const options = program.opts<{ json?: boolean; sessionId?: string }>() - const sessionId = options.sessionId?.trim() - if (options.sessionId !== undefined && sessionId === '') { - program.error('error: --session-id requires a non-empty session id') + // 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() === '') { + reject('error: --session-id requires a non-empty session id') } ctx.provide(HEADLESS_STARTUP_SERVICE, { task, sessionId, - json: options.json === true, + json, } satisfies HeadlessStartupValues) }) parseCmdline(ctx, program) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index d2ad0a24e2..9ae6af1477 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -31,7 +31,7 @@ interface Script { /** Observation stub returned by the `--session-id` query path. */ interface ObservationStub { - header: { cwd: string; origin?: string; parentSession?: string } + header: { cwd?: string; origin?: string; parentSession?: string; agentPreset?: string } [Symbol.dispose](): void } @@ -46,7 +46,7 @@ interface BenchOptions { /** 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' } + preliveMeta?: { cwd?: string; origin?: 'subagent'; agentPreset?: string } } const frameStates = new WeakMap; revision: number; index: number }>() @@ -531,6 +531,34 @@ describe('headless runner', () => { 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' }, + [Symbol.dispose]() {}, + }), + }) + const result = await test.run() + expect(result.code).toBe(1) + expect(result.err).toContain('created under agent preset "minimal"') + 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: {}, + [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', @@ -541,7 +569,7 @@ describe('headless runner', () => { }) const result = await test.run() expect(result.code).toBe(1) - expect(result.err).toContain('belongs to a subagent') + expect(result.err).toContain('is a subagent or forked session') await test.ctx.fiber.dispose() }) @@ -582,7 +610,18 @@ describe('headless runner', () => { }) const result = await test.run() expect(result.code).toBe(1) - expect(result.err).toContain('belongs to a subagent') + 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('created under agent preset "minimal"') await test.ctx.fiber.dispose() }) @@ -609,7 +648,7 @@ describe('headless runner', () => { }) const result = await test.run() expect(result.code).toBe(1) - expect(result.err).toContain('belongs to a subagent') + expect(result.err).toContain('is a subagent or forked session') await test.ctx.fiber.dispose() }) diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 9990ec3a50..51eb6a7417 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -205,6 +205,38 @@ describe('--json projection', () => { }) }) + 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 + 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; 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: 'ééé' }])) diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index cc4ee31334..310dadc587 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -41,6 +41,7 @@ afterEach(async () => { cmdlineInternals.stdout = process.stdout cmdlineInternals.stderr = process.stderr startupInternals.stdinIsTty = () => process.stdin.isTTY + startupInternals.stdout = process.stdout }) /** @@ -81,6 +82,7 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) cmdlineInternals.stdout = observing cmdlineInternals.stderr = observing startupInternals.stdinIsTty = () => options.stdinIsTty === true + startupInternals.stdout = observing const globals = globalThis as unknown as { __headlessStartupApply: typeof apply __headlessStartupObserved: Observed @@ -141,6 +143,37 @@ describe('headless command-line provider', () => { 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: 'error: a task is required, for example: dsh --profile headless "run the tests"', + }) + expect(task).toBeUndefined() + 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.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 }) From 3797127eb2813791915a8394b6a5232adde10ad1 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:16:09 +0800 Subject: [PATCH 07/26] fix(headless): publish the shared chunk and read the session's current preset Address the ds-review-bot v6 review on PR #3849: - Publish the `lib/json-stream-*.js` chunk that both entry points import. Without it the installed tarball failed to load with ERR_MODULE_NOT_FOUND, because `files` listed only `lib/index.js` and `lib/startup.js`. - Resolve a Session's preset from its log (`agent-preset/selected`) instead of only the immutable creation header, so a Session that switched preset while blank is rejected too. Both the persisted and the live path scan the events. - Write the `--json` error event for Commander grammar rejections (an unknown option, a missing option value) as well as the action's own usage errors, and drop Commander's `error: ` prefix from the event message so one event type carries one message shape. - Reject a whitespace-only positional instead of falling back to stdin, so an accidental blank argument cannot consume a pipe. - Document the stdin task as verbatim and the widened usage-error contract. Tests: 68 headless unit tests with per-file 100% coverage on src, 14 profile e2e tests, plus a packed-tarball import smoke check. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +- ...9-headless-machine-readable-run-surface.md | 10 +-- ...eadless-machine-readable-run-surface.zh.md | 10 +-- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 8 +- packages/bundle/headless/README.zh.md | 8 +- packages/bundle/headless/package.json | 1 + packages/bundle/headless/src/index.ts | 42 +++++++++-- packages/bundle/headless/src/startup.ts | 38 ++++++---- .../bundle/headless/tests/headless.spec.ts | 75 ++++++++++++++++++- .../bundle/headless/tests/startup.spec.ts | 22 +++++- 11 files changed, 175 insertions(+), 47 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 7dc3618f54..c5afbd6d57 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: f4fd89b3ef0ae8528574517bb740396bfadec981 -2026-09-09-headless-machine-readable-run-surface.zh.md: 25a41b7fa3d2d46b80e01ca33809e9436db66212 +2026-09-09-headless-machine-readable-run-surface.md: 0284f3b7de060f14bd4eb19b04865a12ba9334b4 +2026-09-09-headless-machine-readable-run-surface.zh.md: 59c8f97941d2af87d098b830bf737f90e76a9ae2 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index f4fd89b3ef..0284f3b7de 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -30,7 +30,7 @@ The change is confined to `packages/bundle/headless`: `src/startup.ts`, `src/ind dsh --profile headless [--json] [--session-id ] [... | -] ``` -Task resolution order: joined positionals, then `-`, then piped stdin. A terminal stdin with no positional task remains a usage error, so an interactive invocation cannot hang waiting for input. 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. In `--json` mode a usage error writes the `error` event before the process exits, because the runner never mounts to write it. +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. @@ -65,17 +65,17 @@ The runtime owns identity. A run without `--session-id` mints `session-` a `--session-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 created 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 was recorded under. 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. ## Consequences -What landed: `src/startup.ts` parses `--json` and `--session-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 or creates the exact session, and wires either the stderr reasoning projection or the new `src/json-stream.ts` projection. `cordis.patch.yml` forwards the two new settings. +What landed: `src/startup.ts` parses `--json` and `--session-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 or creates the exact session, 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. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, or that was created under an agent preset, 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. +- Two consecutive runs with the same `--session-id` share history. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, or that runs under an agent preset, exits 1 with a diagnostic, whether the identity is live or persisted. +- 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: diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 25a41b7fa3..59c8f97941 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -30,7 +30,7 @@ Status: implemented dsh --profile headless [--json] [--session-id ] [... | -] ``` -任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。终端 stdin 且无位置参数仍是用法错误,因此交互式调用不会挂起等待输入。单独的 `-` 是唯一的 stdin 标记:把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,用法错误会在进程退出前写出 `error` 事件,因为 runner 从未挂载来写它。 +任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。只有空白的位置参数本身就属于用法错误,即使 stdin 不是终端也一样,因此误传的空白参数绝不会消费管道内容;完全缺失任务时,仅在 stdin 是终端时属于用法错误。单独的 `-` 是唯一的 stdin 标记:把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。管道任务会原样发送,包括结尾换行。在 `--json` 模式下,所有用法错误——包括 commander 自身的语法拒绝,例如未知选项或选项缺少取值——都会在进程退出前写出 `error` 事件,因为 runner 从未挂载来写它;事件 message 省略 commander 的 `error: ` 前缀,使同一事件类型只承载一种消息形态。 `--json` 只改变 stdout 负载和推理投影的去向。退出码、关闭顺序、会话 flush 和持久化会话日志都不变,因此监督进程对一次运行的分类方式与现在完全一致。 @@ -65,17 +65,17 @@ dsh --profile headless [--json] [--session-id ] [... | -] `--session-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 的工具与提示词运行它,而不是它日志记录时所用的组合。带父会话或子 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` 却没有该服务时显式失败。 ## 后果 -实际落地:`src/startup.ts` 解析 `--json` 与 `--session-id `,把缺失或为 `-` 的任务视为"从 stdin 读取",并且只在 stdin 是终端时抛出用法错误。`src/index.ts` 解析任务、采用或创建精确会话,并接上 stderr 推理投影或新的 `src/json-stream.ts` 投影。`cordis.patch.yml` 转发这两个新设置。 +实际落地:`src/startup.ts` 解析 `--json` 与 `--session-id `,把缺失或为 `-` 的任务视为"从 stdin 读取",并且只在 stdin 是终端时抛出用法错误。`src/index.ts` 解析任务、采用或创建精确会话,并接上 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` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话,或由 agent preset 创建的运行都以诊断退出 1,无论身份是存活还是持久化的。 -- 无位置参数但 stdin 有管道输入时任务被采纳,而交互式无任务调用仍以用法错误失败。 +- 两次连续的相同 `--session-id` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话,或运行在 agent preset 下的运行都以诊断退出 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 期望测试端到端覆盖两种输出模式。 延期与未决: diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index a38fc3c596..ba034cd499 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: b79433c3a5b95224b1c18aec65cb4eafa1e5dc15 -README.zh.md: a822106be6e1dcc972c048fa76c0a22694d4682d +README.md: 950c562cd61047db0b7bb2c72961e49eee0a2799 +README.zh.md: 23331334a6490778f8b18da8f09fa4a4008df42e diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index b79433c3a5..950c562cd6 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -33,7 +33,7 @@ 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: : ` 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: +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: : ` 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 @@ -51,7 +51,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Choosing the session identity -Every invocation defaults to a fresh `session-` identity. Pass `--session-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 was created under an agent preset this profile does not compose, so a supervisor 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-` identity. Pass `--session-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. ### Machine-readable output @@ -63,7 +63,7 @@ Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick ### 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 when stdin is a terminal: nothing runs and the process exits 1. When stdin is not a terminal the runner reads the task from it instead 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 a usage error 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. +`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. ----- @@ -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 created 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 service. - **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. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index a822106be6..23331334a6 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -33,7 +33,7 @@ kind: "package-bundle" dsh --profile headless "run the tests" ``` -agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: : ` 向 stderr 写入错误。任务来自位置参数,参数省略或为单独的 `-` 时则来自 stdin;空白参数或空管道会在任何内容运行前被拒绝。位置参数会原样作为任务,stdin 不会被读取,因此想让管道内容进入提示词时,请把完整提示写进管道: +agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: : ` 向 stderr 写入错误。任务来自位置参数,参数省略或为单独的 `-` 时则来自 stdin;空白位置参数或空管道会在任何内容运行前被拒绝。位置参数会原样作为任务,stdin 不会被读取,因此想让管道内容进入提示词时,请把完整提示写进管道;管道任务会原样发送,包括结尾换行。 ```sh { echo "Summarize these changes:"; git diff --stat; } | dsh --profile headless @@ -51,7 +51,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及由本 profile 不组合的 agent preset 创建的会话,因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 ### 机器可读输出 @@ -63,7 +63,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 帮助与任务错误 -`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。stdin 是终端时,缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。单独的 `-` 是唯一的 stdin 标记;把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,用法错误还会在进程退出前向 stdout 写出 `error` 事件,因此按行解析的监督进程即使 runner 从未挂载也能看到格式良好的事件流。 +`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。只有空白的位置参数本身就属于用法错误——什么都不运行,进程退出 1,即使 stdin 不是终端也一样,因此误传的空白参数绝不会消费管道内容。完全缺失任务时,仅在 stdin 是终端时属于用法错误;stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。单独的 `-` 是唯一的 stdin 标记;把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,所有用法错误——包括 commander 自身的语法拒绝,例如未知选项或选项缺少取值——都会在进程退出前向 stdout 写出 `error` 事件,因此按行解析的监督进程即使 runner 从未挂载也能看到格式良好的事件流;事件 `message` 中的文本不带 commander 的 `error: ` 前缀。 ----- @@ -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 日志的无损副本。 diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index bec09e0e60..9a88edfbad 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -29,6 +29,7 @@ "files": [ "lib/index.js", "lib/startup.js", + "lib/json-stream-*.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 9d64057b89..f2f260b0d4 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -183,15 +183,43 @@ interface AdoptableHeader { agentPreset?: string | undefined } +/** Iterate a live Session's durable events in order. */ +function* liveEvents(session: Session): Generator { + const length = session.seq + for (let seq = 0; seq < length; seq++) { + 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): 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: string } } + if (candidate.type === 'agent-preset/selected') preset = candidate.data.agentPreset + } + return preset +} + /** Reject a Session the one-shot runner must not adopt. */ -function assertAdoptable(header: AdoptableHeader, sessionId: SessionId): void { - if (header.agentPreset !== undefined) { +function assertAdoptable(header: AdoptableHeader, events: Iterable, sessionId: SessionId): void { + const preset = currentPreset(header, events) + 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 the log was recorded under. + // composition its log records. throw new Error( - `session "${sessionId}" was created under agent preset "${header.agentPreset}", ` - + 'which the one-shot runner does not compose', + `session "${sessionId}" runs under agent preset "${preset}", which the one-shot runner does not compose`, ) } if (header.origin === 'subagent' || header.parentSession !== undefined) { @@ -225,7 +253,7 @@ async function resolveAgent( const live = agents.get(sessionId) if (live !== undefined) { // A live identity skips adoption, not the rules that make adoption safe. - assertAdoptable(live.session.header, sessionId) + assertAdoptable(live.session.header, liveEvents(live.session), sessionId) return live } const query = ctx.get('sessionQuery') @@ -234,7 +262,7 @@ async function resolveAgent( } try { using observation = await query.observeSession(sessionId) - assertAdoptable(observation.header, sessionId) + assertAdoptable(observation.header, observation.events, sessionId) const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup }) return agent } catch (error: unknown) { diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 950ee70993..d25662b588 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -68,34 +68,42 @@ Examples: */ export function apply(ctx: Context): void { const program = headlessCommand() - program.action(() => { - const options = program.opts<{ json?: boolean; sessionId?: string }>() - const json = options.json === true - // A usage error is still a process-level failure outside a turn, so a - // --json caller is owed the documented error event even though the runner - // never mounts to write it. - const reject = (message: string): never => { - if (json) internals.stdout.write(`${JSON.stringify(boundJsonEvent({ type: 'error', message }))}\n`) - return program.error(message) + // 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 ((ctx.get('cmdlineArgs')?.get() ?? []).includes('--json')) { + const originalError = program.error.bind(program) + program.error = (message: string, errorOptions?: Parameters[1]): never => { + // The event message matches the runner's runtime errors, which carry no + // commander `error: ` prefix; the stderr line keeps commander's text. + const payload = boundJsonEvent({ type: 'error', message: message.replace(/^error: /, '') }) + internals.stdout.write(`${JSON.stringify(payload)}\n`) + return originalError(message, errorOptions) } + } + program.action(() => { if (program.args.length > 1 && program.args.includes('-')) { - reject('error: `-` must be the only task argument') + program.error('error: `-` must be the only task argument') } const joined = program.args.join(' ') - const task = joined.trim() === '' ? undefined : joined - if (task === undefined && internals.stdinIsTty()) { - reject('error: a task is required, for example: dsh --profile headless "run the tests"') + 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() === '') { - reject('error: --session-id requires a non-empty session id') + program.error('error: --session-id requires a non-empty session id') } ctx.provide(HEADLESS_STARTUP_SERVICE, { task, sessionId, - json, + json: options.json === true, } satisfies HeadlessStartupValues) }) parseCmdline(ctx, program) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 9ae6af1477..e8f7745852 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -32,6 +32,7 @@ interface Script { /** 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 } @@ -102,6 +103,12 @@ 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, options: BenchOptions = {}): Promise<{ ctx: Context @@ -503,6 +510,7 @@ describe('headless runner', () => { sessionId: 'session-exact', observe: () => Promise.resolve({ header: { cwd: process.cwd(), origin: 'user' }, + events: [], [Symbol.dispose]() {}, }), }) @@ -522,6 +530,7 @@ describe('headless runner', () => { sessionId: 'session-exact', observe: () => Promise.resolve({ header: { cwd: '/somewhere/else', origin: 'user' }, + events: [], [Symbol.dispose]() {}, }), }) @@ -536,12 +545,28 @@ describe('headless runner', () => { 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('created under agent preset "minimal"') + 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() }) @@ -550,6 +575,7 @@ describe('headless runner', () => { sessionId: 'session-exact', observe: () => Promise.resolve({ header: {}, + events: [], [Symbol.dispose]() {}, }), }) @@ -564,6 +590,7 @@ describe('headless runner', () => { sessionId: 'session-exact', observe: () => Promise.resolve({ header: { cwd: process.cwd(), origin: 'subagent' }, + events: [], [Symbol.dispose]() {}, }), }) @@ -621,7 +648,50 @@ describe('headless runner', () => { }) const result = await test.run() expect(result.code).toBe(1) - expect(result.err).toContain('created under agent preset "minimal"') + 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('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() }) @@ -643,6 +713,7 @@ describe('headless runner', () => { sessionId: 'session-exact', observe: () => Promise.resolve({ header: { cwd: process.cwd(), origin: 'user', parentSession: 'parent-1' }, + events: [], [Symbol.dispose]() {}, }), }) diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 310dadc587..d0eb31ac7d 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -160,7 +160,7 @@ describe('headless command-line provider', () => { const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string } expect(first).toEqual({ type: 'error', - message: 'error: a task is required, for example: dsh --profile headless "run the tests"', + message: 'a task is required, for example: dsh --profile headless "run the tests"', }) expect(task).toBeUndefined() expect(observed.exits).toEqual([1]) @@ -174,6 +174,22 @@ describe('headless command-line provider', () => { 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.exits).toEqual([1]) + }) + + 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 }) @@ -184,6 +200,10 @@ describe('headless command-line provider', () => { } }) + 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') From 0ca42f3b7892cd9369be2011969856d60c8de71c Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:40:47 +0800 Subject: [PATCH 08/26] fix(headless): bound whole events, re-check adoption after resume, tighten --json scan Address the ds-review-bot v5/v6 review on PR #3849: - Register the shared `lib/json-stream-*.js` chunk in `packageFileExtras` and order `files` as the workspace-constraints gate requires, so `pnpm run constraints` passes. - Re-validate the preset on the Session `agents.resume` actually attached, closing the TOCTOU window between `observeSession` and the write lease. - Bound one serialized event line at 32 KiB: an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type`. - Normalize an empty tool-argument string to `{}`, matching the executor. - Scan only real `--json` flags (stop at `--`, skip a `--session-id` value) before installing the JSON error override. Tests: 74 headless unit tests with per-file 100% coverage on src, 14 profile e2e tests, constraints/typecheck/lint/doc/pairing gates, tarball import smoke. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +-- ...9-headless-machine-readable-run-surface.md | 4 +-- ...eadless-machine-readable-run-surface.zh.md | 4 +-- packages/bundle/headless/README.i18n.yaml | 4 +-- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/headless/src/index.ts | 4 +++ packages/bundle/headless/src/json-stream.ts | 36 +++++++++++++++++-- packages/bundle/headless/src/startup.ts | 19 +++++++++- .../bundle/headless/tests/headless.spec.ts | 17 +++++++++ .../bundle/headless/tests/json-stream.spec.ts | 24 ++++++++++++- .../bundle/headless/tests/startup.spec.ts | 13 +++++++ scripts/check-workspace-constraints.ts | 3 ++ 14 files changed, 123 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index c5afbd6d57..a8eba37969 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: 0284f3b7de060f14bd4eb19b04865a12ba9334b4 -2026-09-09-headless-machine-readable-run-surface.zh.md: 59c8f97941d2af87d098b830bf737f90e76a9ae2 +2026-09-09-headless-machine-readable-run-surface.md: a109bc08817c208ae08a0d1316f3831b7c2656c4 +2026-09-09-headless-machine-readable-run-surface.zh.md: d2a7cc74270abab8051b0ab86cd1854a77554f78 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 0284f3b7de..a109bc0881 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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, and an event with a cut value carries `truncated: true`, including the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter. 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`. 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. @@ -83,7 +83,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, with the terminal `final` event the only exemption. +- 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 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 59c8f97941..d2a7cc7427 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 文本与推理只从已提交的 `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` 事件同样如此;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。 +- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,单条序列化事件行限制在 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 `type` 与 `truncated`。进程级 `error` 事件同样受限;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter;空工具参数字符串会投影为 `{}`,与执行器保持一致。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。 - 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 `final` 结束且没有 `error` 事件,因此即使事件流格式良好,监督进程也要用退出码与 `turn_end` 原因来分类该次运行。 - `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 @@ -83,7 +83,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 每次运行的 `--model` 覆盖尚未实现。后续改动必须尊重 Session Controller 拥有的会话局部选择优先级,而不是覆盖已保存的选择。 - 冷启动加上日志重放会随会话变长而增长,因此长会话每次唤醒的代价高于新会话。 - `--json` 把推理从 stderr 移到 stdout,因此只监听 stderr 的日志收集器在该模式的有推理运行上什么都看不到。 -- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 上限由 `src/json-stream.ts` 拥有,应保持单一常量,终止 `final` 事件是唯一的例外。 +- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 字符串/键上限与 32 KiB 行上限由 `src/json-stream.ts` 拥有,应保持为常量,终止 `final` 事件是唯一的例外。 ## 备选方案 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index ba034cd499..a3472db708 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 950c562cd61047db0b7bb2c72961e49eee0a2799 -README.zh.md: 23331334a6490778f8b18da8f09fa4a4008df42e +README.md: 97ba97eec987f304ac163cbac945bf7764a28d78 +README.zh.md: 526e4ebf12d3a24d9f23ad8e3511f1c5a5e0d4d2 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 950c562cd6..97ba97eec9 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -55,7 +55,7 @@ Every invocation defaults to a fresh `session-` 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. `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`. 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`. 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 diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 23331334a6..526e4ebf12 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -55,7 +55,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 机器可读输出 -`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流;它们在步骤提交时到达,而不是逐 token 到达,默认模式的 stderr 推理仍是唯一的实时文本通道。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串与对象键上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败会写出 `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`。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。 ### 何时使用 diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 9a88edfbad..91cdeb68ae 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -29,8 +29,8 @@ "files": [ "lib/index.js", "lib/startup.js", - "lib/json-stream-*.js", "cordis.patch.yml", + "lib/json-stream-*.js", "lib/types/**/*.d.ts" ], "license": "MIT", diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index f2f260b0d4..44755bc767 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -264,6 +264,10 @@ async function resolveAgent( 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 writer 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 diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index 009e96feb4..afa0d55dd2 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -14,6 +14,9 @@ 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; the terminal `final` is exempt. */ +export const MAX_EVENT_BYTES = 32 * 1024 + /** The stdout sink a projection writes newline-delimited events to. */ export interface JsonSink { /** Write one chunk of the event stream. */ @@ -95,8 +98,37 @@ export function boundJsonEvent( return bounded } -/** Parse raw tool-call arguments, keeping the unparsed string when it is not JSON. */ +/** + * Serialize one projected payload under both limits: every string and key is + * capped at `maxStringBytes`, and the serialized line at `maxEventBytes`. 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. + * @returns the bounded JSON line, without a trailing newline. + */ +export function boundJsonLine( + event: Record, + maxStringBytes: number = MAX_STRING_BYTES, + maxEventBytes: number = MAX_EVENT_BYTES, +): string { + const bounded = boundJsonEvent(event, maxStringBytes) + const line = JSON.stringify(bounded) + if (Buffer.byteLength(line, 'utf8') <= maxEventBytes) return line + const scalars = Object.create(null) as Record + 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') <= maxEventBytes) return short + return JSON.stringify({ type: bounded.type, truncated: true }) +} + +/** Parse raw tool-call arguments as the executor does: empty input is `{}`, invalid JSON stays text. */ function parseArguments(raw: string): unknown { + if (raw === '') return {} try { return JSON.parse(raw) as unknown } catch { @@ -138,7 +170,7 @@ export function projectJsonRun( let stepUsage: SessionEvent<'assistant/message'>['data']['usage'] const write = (event: Record): void => { - sink.write(`${JSON.stringify(boundJsonEvent(event, maxStringBytes))}\n`) + sink.write(`${boundJsonLine(event, maxStringBytes)}\n`) } const onSessionEvent = (session: unknown, event: SessionEvent): void => { diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index d25662b588..2fd96b9b0e 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -60,6 +60,23 @@ Examples: `) } +/** + * 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 task on an interactive stdin @@ -71,7 +88,7 @@ export function apply(ctx: Context): void { // 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 ((ctx.get('cmdlineArgs')?.get() ?? []).includes('--json')) { + if (jsonRequested(ctx.get('cmdlineArgs')?.get() ?? [])) { const originalError = program.error.bind(program) program.error = (message: string, errorOptions?: Parameters[1]): never => { // The event message matches the runner's runtime errors, which carry no diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index e8f7745852..5674700978 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -672,6 +672,23 @@ describe('headless runner', () => { 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('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('fails when a live event below the captured Session length cannot be read', async () => { let capturedLength = 0 const test = await bench({ diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 51eb6a7417..0718a718f1 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/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, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts' +import { boundJsonEvent, boundJsonLine, MAX_STRING_BYTES, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts' interface ProjectionHarness { readonly lines: string[] @@ -243,6 +243,28 @@ describe('--json projection', () => { 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('writes the terminal final event without bounding its answer', () => { const test = harness({ maxStringBytes: 4 }) test.projection.finish('abcdefgh') diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index d0eb31ac7d..e8697746b4 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -182,6 +182,19 @@ describe('headless command-line provider', () => { 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') diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e7b0e948e8..f265bb5ca5 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -184,6 +184,9 @@ const packageFileExtras: Readonly> = { // 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 { From 3488db77bfee956768147b88941ac521469b0656 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:06:29 +0800 Subject: [PATCH 09/26] fix(headless): cap the bounding recursion depth and route error events through the line cap Address the ds-review-bot v6 review on PR #3849: - Cut a projected payload nested deeper than 64 levels instead of recursing until the stack overflows; the isolated session listener would otherwise drop the `tool_call` silently. - Write both `error` events (startup usage errors and runner failures) through `boundJsonLine`, so the documented 32 KiB line cap covers JSON-escaping expansion as well as the 8 KiB message cap. Tests: 76 headless unit tests with per-file 100% coverage on src, 14 profile e2e tests, constraints/typecheck/lint/doc/pairing gates. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +-- ...9-headless-machine-readable-run-surface.md | 2 +- ...eadless-machine-readable-run-surface.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 4 +-- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 4 +-- packages/bundle/headless/src/json-stream.ts | 26 +++++++++++++++---- packages/bundle/headless/src/startup.ts | 6 ++--- .../bundle/headless/tests/json-stream.spec.ts | 18 +++++++++++++ 10 files changed, 52 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index a8eba37969..807fd6a257 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: a109bc08817c208ae08a0d1316f3831b7c2656c4 -2026-09-09-headless-machine-readable-run-surface.zh.md: d2a7cc74270abab8051b0ab86cd1854a77554f78 +2026-09-09-headless-machine-readable-run-surface.md: 0a243e26442d3e4546f2c6cd23d09ea2d5d19606 +2026-09-09-headless-machine-readable-run-surface.zh.md: 95001e73a6e8d3bb0406849ee82ec8da5f2f768f diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index a109bc0881..0a243e2644 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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`. 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 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. - 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. diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index d2a7cc7427..95001e73a6 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 文本与推理只从已提交的 `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`。进程级 `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 计量。 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index a3472db708..ed0f54a8bc 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 97ba97eec987f304ac163cbac945bf7764a28d78 -README.zh.md: 526e4ebf12d3a24d9f23ad8e3511f1c5a5e0d4d2 +README.md: 68558f272fdfb1d91220034c6dfcc8910a456195 +README.zh.md: e649422f6aa37fb2ce779768304912f8f6b71549 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 97ba97eec9..68558f272f 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -55,7 +55,7 @@ Every invocation defaults to a fresh `session-` 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. `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`. 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 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. ### When to use it diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 526e4ebf12..e649422f6a 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -55,7 +55,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 机器可读输出 -`--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`。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致。轮次之外的进程级失败会写出 `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` 原因作为失败信号。 ### 何时使用 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 44755bc767..33102f102f 100644 --- a/packages/bundle/headless/src/index.ts +++ b/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, boundJsonEvent } from './json-stream.ts' +import { projectJsonRun, boundJsonLine } from './json-stream.ts' /** Stable Cordis plugin name. */ export const name = 'headless-runner' @@ -284,7 +284,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(boundJsonEvent({ type: 'error', message }))}\n`) + if (json) io.stdout.write(`${boundJsonLine({ type: 'error', message })}\n`) io.stderr.write(`dsh: ${message}\n`) io.exit(1) } diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index afa0d55dd2..3fa4c6c5ed 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -58,22 +58,38 @@ function boundKey(key: string, maxBytes: number, state: BoundState): string { 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): unknown { +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)) return value.map(item => boundValue(item, maxBytes, state)) + 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 for (const [key, item] of Object.entries(value)) { - bounded[boundKey(key, maxBytes, state)] = boundValue(item, maxBytes, state) + bounded[boundKey(key, maxBytes, state)] = boundValue(item, maxBytes, state, depth + 1) } return bounded } @@ -82,8 +98,8 @@ function boundValue(value: unknown, maxBytes: number, state: BoundState): unknow /** * Bound every string and key in one projected payload, adding `truncated: true` - * when any was cut. Exported so the runner applies the same limit to its - * process-level `error` event. + * when any was cut. {@link boundJsonLine} composes this with the whole-line cap; + * this form stays exported for callers that need the bounded object. * @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. diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 2fd96b9b0e..a0f6bb53cb 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -9,7 +9,7 @@ import { Command } from 'commander' import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' -import { boundJsonEvent } from './json-stream.ts' +import { boundJsonLine } from './json-stream.ts' /** Stable Cordis plugin name. */ export const name = 'headless-startup' @@ -93,8 +93,8 @@ export function apply(ctx: Context): void { program.error = (message: string, errorOptions?: Parameters[1]): never => { // The event message matches the runner's runtime errors, which carry no // commander `error: ` prefix; the stderr line keeps commander's text. - const payload = boundJsonEvent({ type: 'error', message: message.replace(/^error: /, '') }) - internals.stdout.write(`${JSON.stringify(payload)}\n`) + const payload = boundJsonLine({ type: 'error', message: message.replace(/^error: /, '') }) + internals.stdout.write(`${payload}\n`) return originalError(message, errorOptions) } } diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 0718a718f1..64c9a25a3e 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -265,6 +265,24 @@ describe('--json projection', () => { 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')).toBeLessThanOrEqual(32 * 1024) + expect(JSON.parse(line)).toEqual({ type: 'error', 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('writes the terminal final event without bounding its answer', () => { const test = harness({ maxStringBytes: 4 }) test.projection.finish('abcdefgh') From d2dc505830c00d2bc17df798e15f93e45952c080 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:28:34 +0800 Subject: [PATCH 10/26] ci: re-trigger the pull-request run after the superseded run was dropped From b3756a89d1d1bd5bd5f6c7e30c6f0d1b8eeead9f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:00:06 +0800 Subject: [PATCH 11/26] 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. --- ...less-machine-readable-run-surface.i18n.yaml | 4 ++-- ...09-headless-machine-readable-run-surface.md | 4 ++-- ...headless-machine-readable-run-surface.zh.md | 4 ++-- packages/bundle/headless/README.i18n.yaml | 4 ++-- packages/bundle/headless/README.md | 6 +++--- packages/bundle/headless/README.zh.md | 6 +++--- packages/bundle/headless/src/index.ts | 6 ++++++ packages/bundle/headless/src/json-stream.ts | 5 ++--- .../bundle/headless/tests/headless.spec.ts | 18 ++++++++++++++++++ .../bundle/headless/tests/json-stream.spec.ts | 8 ++++---- 10 files changed, 44 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 807fd6a257..8436071c59 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 0a243e2644..a2889ee179 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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-` a `--session-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 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 95001e73a6..d8f2a8284f 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 文本与推理只从已提交的 `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 ] [... | -] `--session-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` 服务,同样显式失败。 ## 后果 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index ed0f54a8bc..4628a0bb9e 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 68558f272f..81e978b586 100644 --- a/packages/bundle/headless/README.md +++ b/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-` identity. Pass `--session-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-` identity. Pass `--session-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. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index e649422f6a..2125c4c5cc 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -51,11 +51,11 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识。传入 `--session-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 日志的无损副本。 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 33102f102f..f12f3cd32f 100644 --- a/packages/bundle/headless/src/index.ts +++ b/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() }, diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index 3fa4c6c5ed..f431070f26 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/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, maxStringBytes: number = MAX_STRING_BYTES, ): Record { diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 5674700978..2ca293eba3 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -44,6 +44,8 @@ interface BenchOptions { sessionId?: string json?: boolean observe?: () => Promise + /** 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) }, diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 64c9a25a3e..034f410f48 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/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' }) }) }) From a5c21a69b8abe545981faad7a3bda0041aa36de2 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:17:01 +0800 Subject: [PATCH 12/26] fix(headless): require persistence for every --session-id path Address the ds-review-bot v6 review on PR #3849: the guard sat before the create branch only, so adopting an already-live identity could still run a session whose history dies at exit. Move it to the top of resolveAgent, where it covers both the live and the create path, and add a rejection test for the live path alongside the existing create-path one. --- ...dless-machine-readable-run-surface.i18n.yaml | 4 ++-- ...-09-headless-machine-readable-run-surface.md | 2 +- ...-headless-machine-readable-run-surface.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 4 ++-- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 13 +++++++------ packages/bundle/headless/tests/headless.spec.ts | 17 ++++++++++++++++- 8 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 8436071c59..f897e5dc54 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: a2889ee17923407d659d133938445947ae21ff17 -2026-09-09-headless-machine-readable-run-surface.zh.md: d8f2a8284fb8d078b7171ffdc26f88a475eb76c9 +2026-09-09-headless-machine-readable-run-surface.md: 9e0e6165b195de8dc96dc61512a56af7164c8bf6 +2026-09-09-headless-machine-readable-run-surface.zh.md: 18cf93a13d7dc03a09c70d14d352f8f5f690e025 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index a2889ee179..9e0e6165b1 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -65,7 +65,7 @@ The runtime owns identity. A run without `--session-id` mints `session-` a `--session-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, or when creating the requested identity would lack the `sessionPersistence` service that makes it durable. +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 the requested identity would lack the `sessionPersistence` service that makes it durable. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index d8f2a8284f..18cf93a13d 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] `--session-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` 却没有该服务时显式失败;若创建所请求的身份时缺少让它持久化的 `sessionPersistence` 服务,同样显式失败。 +采用时会比较持久化会话记录的 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` 服务,同样显式失败。 ## 后果 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 4628a0bb9e..180a463848 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 81e978b5863d1271314cb58b7bab25a383bc08da -README.zh.md: 2125c4c5cc310059cbd00dabc4b0bb01cd33de1d +README.md: 138da22830a7ce93635418912266b816540f9db1 +README.zh.md: 3388ea5e729d4e4aeb1d434427768d05165bb22a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 81e978b586..138da22830 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -51,7 +51,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Choosing the session identity -Every invocation defaults to a fresh `session-` identity. Pass `--session-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. +Every invocation defaults to a fresh `session-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require 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 diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 2125c4c5cc..3388ea5e72 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -51,7 +51,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建——创建要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 ### 机器可读输出 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index f12f3cd32f..357bce2d87 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -250,6 +250,13 @@ async function resolveAgent( agentOptions: { provider: string; model: string }, setup: (agentCtx: Context) => void, ): Promise { + // Adopting a live identity and creating a missing one both promise 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 either path. + if (ctx.get('sessionPersistence') === undefined) { + throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process') + } const live = agents.get(sessionId) if (live !== undefined) { // A live identity skips adoption, not the rules that make adoption safe. @@ -272,12 +279,6 @@ 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() }, diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 2ca293eba3..c65a88562b 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -506,7 +506,7 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) - it('rejects creating the requested Session when persistence is not mounted', async () => { + it('rejects --session-id when persistence is not mounted', async () => { const test = await bench({ afterPrompt(session, message) { appendTurn(session, 1, message, 'created', true) }, }, { @@ -521,6 +521,21 @@ describe('headless runner', () => { 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) }, From 7a4a55f30b6794db0fb482445a28a2e95bd176d3 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:39:31 +0800 Subject: [PATCH 13/26] fix(headless): fail closed on malformed preset records and unroundtrippable args Address the ds-review-bot v6 review on PR #3849: - A persisted `agent-preset/selected` record whose `data.agentPreset` is not a non-empty string used to read as "no preset", letting the run continue under this bundle's composition. It now fails closed. - A live identity now has to be known to `sessionPersistence` (`stat`), since an Agent registered only in memory has no write handle and would flush nothing. - Tool arguments that JSON cannot round-trip (an overflowing number such as `1e400`) keep their raw text instead of the `null` JSON.stringify reports. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +- ...9-headless-machine-readable-run-surface.md | 6 +-- ...eadless-machine-readable-run-surface.zh.md | 6 +-- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 6 +-- packages/bundle/headless/README.zh.md | 6 +-- packages/bundle/headless/src/index.ts | 25 +++++++++--- packages/bundle/headless/src/json-stream.ts | 9 ++++- .../bundle/headless/tests/headless.spec.ts | 38 ++++++++++++++++++- .../bundle/headless/tests/json-stream.spec.ts | 9 +++++ 10 files changed, 90 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index f897e5dc54..8fe75f0a30 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: 9e0e6165b195de8dc96dc61512a56af7164c8bf6 -2026-09-09-headless-machine-readable-run-surface.zh.md: 18cf93a13d7dc03a09c70d14d352f8f5f690e025 +2026-09-09-headless-machine-readable-run-surface.md: 7bcca9226a8620d1eae83ecb269f680e6d1c54d4 +2026-09-09-headless-machine-readable-run-surface.zh.md: ce7047a4113c7ff794a3cbb88976c4760764ca2f diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 9e0e6165b1..7bcca9226a 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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 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. +- 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, an empty tool-argument string projects as `{}` to match the executor, and arguments JSON cannot round-trip (an overflowing number such as `1e400`) keep their raw text instead of the `null` serialization 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`, 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-` a `--session-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, or when the requested identity would lack the `sessionPersistence` service that makes it durable. +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. 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 the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing. ## Consequences @@ -74,7 +74,7 @@ What landed: `src/startup.ts` parses `--json` and `--session-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. - 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, that recorded no cwd, that is a subagent or forked session, or that runs under an agent preset, exits 1 with a diagnostic, whether the identity is live or persisted. +- Two consecutive runs with the same `--session-id` share history. 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 live identity has no stored record, exits 1 with a diagnostic, whether the identity is live or persisted. - 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. diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 18cf93a13d..ce7047a411 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 文本与推理只从已提交的 `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;空工具参数字符串会投影为 `{}`,与执行器保持一致;JSON 无法往返的参数(例如溢出为 `Infinity` 的 `1e400`)保留原始文本,而不是 `null` 序列化结果。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。 - 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 `final` 结束且没有 `error` 事件,因此即使事件流格式良好,监督进程也要用退出码与 `turn_end` 原因来分类该次运行。 - `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 @@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] `--session-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` 却没有该服务时显式失败;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败。 +采用时会比较持久化会话记录的 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 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。 ## 后果 @@ -74,7 +74,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。 - `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。 - 发生重试的步骤只为最终提交的 attempt 发布 `text` 与 `thinking`,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。 -- 两次连续的相同 `--session-id` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话,或运行在 agent preset 下的运行都以诊断退出 1,无论身份是存活还是持久化的。 +- 两次连续的相同 `--session-id` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或存活身份没有持久化记录的运行都以诊断退出 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 期望测试端到端覆盖两种输出模式。 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 180a463848..11d9c437a2 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 138da22830a7ce93635418912266b816540f9db1 -README.zh.md: 3388ea5e729d4e4aeb1d434427768d05165bb22a +README.md: c21084314349471258b64584d00266449dc57c88 +README.zh.md: 63129a4803b2dcde24866fb7a112ecce84fc1b62 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 138da22830..c210843143 100644 --- a/packages/bundle/headless/README.md +++ b/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-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require 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. +Every invocation defaults to a fresh `session-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require the composed `sessionPersistence` service, so a profile that omits it fails loudly instead of returning an id whose history dies with the process; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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 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. +`--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, while arguments JSON cannot round-trip — an overflowing number such as `1e400` — keep their raw text instead of the `null` serialization would report. 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 and persistence services. +- **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 plus a stored record for a live identity. - **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. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 3388ea5e72..63129a4803 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -51,11 +51,11 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 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 层及以上的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致。轮次之外的进程级失败会写出 `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 层及以上的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致;而 JSON 无法往返的参数——例如溢出为 `Infinity` 的数字 `1e400`——会保留原始文本,而不是 `null` 序列化结果。轮次之外的进程级失败会写出 `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、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务,且存活身份必须已有持久化记录。 - **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 357bce2d87..2bb07195f6 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -200,20 +200,27 @@ function* liveEvents(session: Session): Generator { * 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): string | undefined { +function currentPreset(header: AdoptableHeader, events: Iterable, 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: string } } - if (candidate.type === 'agent-preset/selected') preset = candidate.data.agentPreset + 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, sessionId: SessionId): void { - const preset = currentPreset(header, events) + 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 @@ -254,13 +261,21 @@ async function resolveAgent( // 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 either path. - if (ctx.get('sessionPersistence') === undefined) { + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) { throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process') } const live = agents.get(sessionId) if (live !== undefined) { // A live identity skips adoption, not the rules that make adoption safe. assertAdoptable(live.session.header, liveEvents(live.session), sessionId) + // The service can be mounted while this particular Agent was registered in + // memory (a custom factory or direct `agents.register`); the backend then + // holds no write handle for it and `session/flush` stores nothing. A stored + // record proves the id is actually persistence-backed. + if (await persistence.stat(sessionId) === undefined) { + throw new Error(`live session "${sessionId}" has no persisted record, so the one-shot runner cannot promise it survives this process`) + } return live } const query = ctx.get('sessionQuery') diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index f431070f26..bbab2e166c 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -144,8 +144,15 @@ export function boundJsonLine( /** Parse raw tool-call arguments as the executor does: empty input is `{}`, invalid JSON stays text. */ function parseArguments(raw: string): unknown { if (raw === '') return {} + const seen = { nonFinite: false } try { - return JSON.parse(raw) as unknown + 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 } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index c65a88562b..3017785aaf 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -46,6 +46,8 @@ interface BenchOptions { observe?: () => Promise /** Leave the persistence service unmounted to exercise the fail-loud path. */ omitPersistence?: boolean + /** Mount the service but make it report no stored record for any id. */ + unbackedPersistence?: boolean /** Register a live Agent under `sessionId` before the runner starts. */ prelive?: boolean /** Header facts for that pre-registered live Agent. */ @@ -175,7 +177,11 @@ 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) + if (options.omitPersistence !== true) { + ctx.provide('sessionPersistence', { + stat: () => Promise.resolve(options.unbackedPersistence === true ? undefined : { header: {} }), + } as never) + } return { ctx, output: () => ({ out, err, order: [...order] }), @@ -536,6 +542,21 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('rejects a live Session the persistence service does not know', async () => { + const test = await bench({ + afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) }, + }, { + sessionId: 'session-exact', + prelive: true, + unbackedPersistence: true, + }) + const result = await test.run() + expect(result.code).toBe(1) + expect(result.err).toContain('has no persisted record') + 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) }, @@ -603,6 +624,21 @@ describe('headless runner', () => { 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', diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 034f410f48..d7c596b936 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -205,6 +205,15 @@ describe('--json projection', () => { }) }) + 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({ From 755b44bbcdb58b10dc6f4b7380e7b98edaabc756 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:53:03 +0800 Subject: [PATCH 14/26] fix(headless): require the query service for every --session-id run Address the ds-review-bot v9 review on PR #3849: - Require `sessionQuery` before the live fast path too: a later process has no live Agent and must find the id through the query service, so a live identity no longer bypasses the check. - Soften the live `stat` comment to what it proves: a stored record rules out an in-memory registration, while write-handle ownership is not queryable. - Complete the `parseArguments` JSDoc and fix the broken English sentence in README.md and the Agent Note. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 ++-- ...9-headless-machine-readable-run-surface.md | 4 ++-- ...eadless-machine-readable-run-surface.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 4 ++-- packages/bundle/headless/README.md | 4 ++-- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 18 +++++++------- packages/bundle/headless/src/json-stream.ts | 5 +++- .../bundle/headless/tests/headless.spec.ts | 24 ++++++++++++++++--- 9 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 8fe75f0a30..16160a054f 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: 7bcca9226a8620d1eae83ecb269f680e6d1c54d4 -2026-09-09-headless-machine-readable-run-surface.zh.md: ce7047a4113c7ff794a3cbb88976c4760764ca2f +2026-09-09-headless-machine-readable-run-surface.md: 9bc7a55beb86d0836288e376df6b544bd14c5222 +2026-09-09-headless-machine-readable-run-surface.zh.md: c8577c7849010e8bacf697a6b5a008c57a4b33ee diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 7bcca9226a..9bc7a55beb 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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 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 JSON cannot round-trip (an overflowing number such as `1e400`) keep their raw text instead of the `null` serialization would report. 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, 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`, 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-` a `--session-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, 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. 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 the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing. +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. 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 — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index ce7047a411..c8577c7849 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] `--session-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 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。 +采用时会比较持久化会话记录的 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 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 id,因为后续进程仍需找到它;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。 ## 后果 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 11d9c437a2..36361c1e59 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: c21084314349471258b64584d00266449dc57c88 -README.zh.md: 63129a4803b2dcde24866fb7a112ecce84fc1b62 +README.md: 8e9e57d8126715d8dcf6e83bb74e788e2f1e88c7 +README.zh.md: 3a8f28763dfc670e02fa86493a1aacd1c0a20921 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index c210843143..8e9e57d812 100644 --- a/packages/bundle/headless/README.md +++ b/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-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require the composed `sessionPersistence` service, so a profile that omits it fails loudly instead of returning an id whose history dies with the process; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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. +Every invocation defaults to a fresh `session-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require 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; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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 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 JSON cannot round-trip — an overflowing number such as `1e400` — keep their raw text instead of the `null` serialization would report. 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, 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 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 diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 63129a4803..3a8f28763d 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -51,7 +51,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 ### 机器可读输出 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 2bb07195f6..cc2e987fcd 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -265,23 +265,25 @@ async function resolveAgent( if (persistence === 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 even when this + // process already has the identity live. + 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 identity skips adoption, not the rules that make adoption safe. assertAdoptable(live.session.header, liveEvents(live.session), sessionId) - // The service can be mounted while this particular Agent was registered in - // memory (a custom factory or direct `agents.register`); the backend then - // holds no write handle for it and `session/flush` stores nothing. A stored - // record proves the id is actually persistence-backed. + // A stored record rules out an Agent registered only in memory, whose + // `session/flush` would store nothing; write-handle ownership itself is + // not queryable through the persistence contract. if (await persistence.stat(sessionId) === undefined) { throw new Error(`live session "${sessionId}" has no persisted record, so the one-shot runner cannot promise it survives this process`) } 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) assertAdoptable(observation.header, observation.events, sessionId) diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index bbab2e166c..59de1c740c 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -141,7 +141,10 @@ export function boundJsonLine( return JSON.stringify({ type: bounded.type, truncated: true }) } -/** Parse raw tool-call arguments as the executor does: empty input is `{}`, invalid JSON stays text. */ +/** + * 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 } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 3017785aaf..4b38a4dc44 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -44,6 +44,8 @@ interface BenchOptions { sessionId?: string json?: boolean observe?: () => Promise + /** 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 /** Mount the service but make it report no stored record for any id. */ @@ -174,8 +176,9 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ return { agent, dispose: () => Promise.resolve() } }, }) - if (options.observe !== undefined) { - ctx.provide('sessionQuery', { observeSession: () => options.observe!() } as never) + 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', { @@ -670,13 +673,28 @@ describe('headless runner', () => { }) it('requires the Session query service for an exact Session identity', async () => { - const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact' }) + 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('reuses a live Agent already registered under the requested identity', async () => { const test = await bench({ afterPrompt(session, message) { appendTurn(session, 1, message, 'live answer', true) }, From b3f6c5e9913f44948299a29d00b3597c83c259e9 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:16:58 +0800 Subject: [PATCH 15/26] fix(headless): count the line terminator inside the 32 KiB event cap Address the ds-review-bot v10 review on PR #3849: `boundJsonLine` bounded the serialized JSON but not the newline the writer appends, so an event that serialized to exactly 32 KiB produced a 32 KiB + 1 byte record. Reserve the terminator byte inside the cap, document the cap as newline-inclusive, and assert the written record length in the boundary tests. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 ++-- ...9-headless-machine-readable-run-surface.md | 2 +- ...eadless-machine-readable-run-surface.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 4 ++-- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/json-stream.ts | 20 ++++++++++++------- .../bundle/headless/tests/json-stream.spec.ts | 10 +++++++++- 8 files changed, 30 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 16160a054f..2e12d6bd1d 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: 9bc7a55beb86d0836288e376df6b544bd14c5222 -2026-09-09-headless-machine-readable-run-surface.zh.md: c8577c7849010e8bacf697a6b5a008c57a4b33ee +2026-09-09-headless-machine-readable-run-surface.md: 4afb0e68b0eba335b480f8d954a1afe559741d1e +2026-09-09-headless-machine-readable-run-surface.zh.md: e55476a733fbda6904b203571b87c48cc3cd2f26 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 9bc7a55beb..4afb0e68b0 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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 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. +- 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`, 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. diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index c8577c7849..e55476a733 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 文本与推理只从已提交的 `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`)保留原始文本,而不是 `null` 序列化结果。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。 +- 每个被投影的字符串与对象键都限制在 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` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 36361c1e59..3f3d8f42a6 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 8e9e57d8126715d8dcf6e83bb74e788e2f1e88c7 -README.zh.md: 3a8f28763dfc670e02fa86493a1aacd1c0a20921 +README.md: 2eabc77bca147a2e754284cbecb938a1430ab546 +README.zh.md: 999c2327245ab75ca8557718338e4b8ca52d1990 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 8e9e57d812..2eabc77bca 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -55,7 +55,7 @@ Every invocation defaults to a fresh `session-` 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. `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, 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 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, 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 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 diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 3a8f28763d..999c232724 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -55,7 +55,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 机器可读输出 -`--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`——会保留原始文本,而不是 `null` 序列化结果。轮次之外的进程级失败会写出 `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 层及以上的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致;而 JSON 无法往返的参数——例如溢出为 `Infinity` 的数字 `1e400`——会保留原始文本,而不是 `JSON.stringify` 会报告的 `null`。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。 ### 何时使用 diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index 59de1c740c..b292f49671 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -14,9 +14,12 @@ 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; the terminal `final` is exempt. */ +/** 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. */ @@ -115,12 +118,14 @@ function boundJsonEvent( /** * Serialize one projected payload under both limits: every string and key is - * capped at `maxStringBytes`, and the serialized line at `maxEventBytes`. 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. + * 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. + * @param maxEventBytes - cap on the serialized line plus its trailing newline. * @returns the bounded JSON line, without a trailing newline. */ export function boundJsonLine( @@ -128,16 +133,17 @@ export function boundJsonLine( 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') <= maxEventBytes) return line + if (Buffer.byteLength(line, 'utf8') <= limit) return line const scalars = Object.create(null) as Record 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') <= maxEventBytes) return short + if (Buffer.byteLength(short, 'utf8') <= limit) return short return JSON.stringify({ type: bounded.type, truncated: true }) } diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index d7c596b936..0984dde2d5 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -276,10 +276,18 @@ describe('--json projection', () => { 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')).toBeLessThanOrEqual(32 * 1024) + 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] From 980b4b77af0b3b263becceb79c406aa358e1a8ba Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:46:16 +0800 Subject: [PATCH 16/26] fix(headless): close the v11 review gaps in the json run surface Address the ds-review-bot v11 review on PR #3849: - Accumulate every attempt's usage into `step_end.usage`, including a retried attempt whose only sample sits in its discarded `assistant/attempt` stream. - Re-check the adopted Session after the idle wait, so a preset selected in that window is still rejected. - Reject a whitespace-only `sessionId` supplied through a Cordis overlay, which bypasses the CLI trim check. - Narrow the documented error contract: a profile whose own plugins fail to load exits before the runner mounts and keeps only stderr diagnostics. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +- ...9-headless-machine-readable-run-surface.md | 6 +- ...eadless-machine-readable-run-surface.zh.md | 6 +- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 11 +++ packages/bundle/headless/src/json-stream.ts | 46 +++++++++- .../bundle/headless/tests/headless.spec.ts | 36 +++++++- .../bundle/headless/tests/json-stream.spec.ts | 92 +++++++++++++++++++ 10 files changed, 192 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 2e12d6bd1d..7143b513a2 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: 4afb0e68b0eba335b480f8d954a1afe559741d1e -2026-09-09-headless-machine-readable-run-surface.zh.md: e55476a733fbda6904b203571b87c48cc3cd2f26 +2026-09-09-headless-machine-readable-run-surface.md: 212fd956b833cedf90804340cff22b85ce9f38aa +2026-09-09-headless-machine-readable-run-surface.zh.md: 32986ab8f950eb14c687aece7902d0e11b31e572 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 4afb0e68b0..212fd956b8 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -46,7 +46,7 @@ Task resolution order: joined positionals, then `-`, then piped stdin. A whitesp | `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` | process-level failure outside a turn | +| `error` | `message` | a failure the runner raises outside a turn | | `final` | `text` | last line | Projection rules: @@ -56,7 +56,7 @@ Projection rules: - 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`, matching the token accounting a provider reports per step. +- `usage` appears on `step_end`, summing every attempt in the step — including a retried attempt whose only usage sample sits in its discarded `assistant/attempt` stream — so it matches the token accounting the provider billed for that step. - Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary. ### Session identity @@ -65,7 +65,7 @@ The runtime owns identity. A run without `--session-id` mints `session-` a `--session-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, 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. 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 — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing. +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. All checks run when a live Agent already holds the requested id, and again after the runner's idle wait, so a live identity cannot bypass them and 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 — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index e55476a733..32986ab8f9 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -46,7 +46,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] | `thinking` | `text` | 一个已提交的推理块 | | `tool_call` | `callId`、`tool`、`input` | 每次调用一条 | | `tool_result` | `callId`、`status`、`result` | 每次追加的结果一条 | -| `error` | `message` | 轮次之外的进程级失败 | +| `error` | `message` | runner 在轮次之外抛出的失败 | | `final` | `text` | 最后一行 | 投影规则: @@ -56,7 +56,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - `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` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。 +- `usage` 出现在 `step_end` 上,累计该步的每一次 attempt——包括仅在被丢弃的 `assistant/attempt` 流中留下用量样本的重试——与 provider 为该步计费的 token 计量一致。 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 ### 会话身份 @@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] `--session-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 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 id,因为后续进程仍需找到它;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。 +采用时会比较持久化会话记录的 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 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,并在 runner 等待 idle 后再次执行,因此存活身份无法绕过它们,在该窗口内选中的 preset 也仍会被拒绝。纯空白的 `sessionId` 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 id,因为后续进程仍需找到它;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。 ## 后果 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 3f3d8f42a6..98abf47f56 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 2eabc77bca147a2e754284cbecb938a1430ab546 -README.zh.md: 999c2327245ab75ca8557718338e4b8ca52d1990 +README.md: 8bdad30de4de3e1e2c4921c2f879454c3440822f +README.zh.md: 1675c026f4b82752d2e7a5ae1a51b320b8eb0f27 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 2eabc77bca..8bdad30de4 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -55,7 +55,7 @@ Every invocation defaults to a fresh `session-` 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. `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 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, 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 diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 999c232724..1675c026f4 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -55,7 +55,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量 ### 机器可读输出 -`--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`。轮次之外的进程级失败会写出 `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 层及以上的负载会在该深度被截断。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致;而 JSON 无法往返的参数——例如溢出为 `Infinity` 的数字 `1e400`——会保留原始文本,而不是 `JSON.stringify` 会报告的 `null`。runner 在轮次之外抛出的失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行;若 profile 自身的插件加载失败,进程会在 runner 挂载前退出,该情形只保留 loader 的 stderr 诊断。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。 ### 何时使用 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index cc2e987fcd..11f44c846e 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -329,6 +329,12 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise // 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 @@ -356,6 +362,11 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise })).agent : await resolveAgent(ctx, agents, sessionId, agentOptions, setup) await agent.whenIdle() + if (config.sessionId !== undefined) { + // A live Session can select an agent preset while the runner awaits idle; + // re-read its log so the rejection cannot be outrun by that timing. + assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId) + } const firstSeq = agent.session.seq const projection = config.json === true ? projectJsonRun(ctx, agent, io.stdout) : undefined const stopReasoning = projection === undefined ? streamReasoning(ctx, agent, io.stderr) : undefined diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index b292f49671..c2a602a2ff 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -9,6 +9,7 @@ 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. */ @@ -176,6 +177,42 @@ function resultText(blocks: readonly { type: string; text?: string }[]): string .join('') } +/** Provider token accounting for one step, as carried by `assistant/message`. */ +type StepUsage = NonNullable['data']['usage']> + +/** 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 optional bucket is summed only when + * every contribution reports it. + * @param total - usage accumulated so far. + * @param next - usage reported by the next attempt. + * @returns the accumulated usage, or `undefined` when neither side reports any. + */ +function addUsage(total: StepUsage | undefined, next: StepUsage | undefined): StepUsage | undefined { + if (next === undefined) return total + if (total === undefined) return next + 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 { + inputTokens: total.inputTokens + next.inputTokens, + outputTokens: total.outputTokens + next.outputTokens, + ...totalTokens === undefined ? {} : { totalTokens }, + ...cacheReadTokens === undefined ? {} : { cacheReadTokens }, + ...cacheWriteTokens === undefined ? {} : { cacheWriteTokens }, + ...reasoningTokens === undefined ? {} : { reasoningTokens }, + } +} + /** * Project one Agent's run as newline-delimited JSON on `sink`. * @@ -198,7 +235,7 @@ export function projectJsonRun( ): JsonProjection { const maxStringBytes = options.maxStringBytes ?? MAX_STRING_BYTES let disposed = false - let stepUsage: SessionEvent<'assistant/message'>['data']['usage'] + let stepUsage: StepUsage | undefined const write = (event: Record): void => { sink.write(`${boundJsonLine(event, maxStringBytes)}\n`) @@ -213,8 +250,13 @@ export function projectJsonRun( 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 = event.data.usage + 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 }) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 4b38a4dc44..67e0576967 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -54,6 +54,8 @@ interface BenchOptions { prelive?: boolean /** Header facts for that pre-registered live Agent. */ preliveMeta?: { cwd?: string; origin?: 'subagent'; agentPreset?: string } + /** Run when the live path reads persistence, e.g. to mutate the live log. */ + onStat?: (agent: Agent) => void } const frameStates = new WeakMap; revision: number; index: number }>() @@ -180,9 +182,13 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ const observe = options.observe ?? (() => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND'))) ctx.provide('sessionQuery', { observeSession: () => observe() } as never) } + let preliveAgent: Agent | undefined if (options.omitPersistence !== true) { ctx.provide('sessionPersistence', { - stat: () => Promise.resolve(options.unbackedPersistence === true ? undefined : { header: {} }), + stat: () => { + if (options.onStat !== undefined && preliveAgent !== undefined) options.onStat(preliveAgent) + return Promise.resolve(options.unbackedPersistence === true ? undefined : { header: {} }) + }, } as never) } return { @@ -197,10 +203,10 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) }) }) if (options.prelive === true || options.preliveMeta !== undefined) { - await ctx.agents.create({ + preliveAgent = (await ctx.agents.create({ sessionId: brandString(options.sessionId ?? 'session-exact'), meta: { cwd: process.cwd(), ...options.preliveMeta }, - }) + })).agent } apply(ctx, { ...options.useStdin === true ? {} : { task: options.task ?? 'do the thing' }, @@ -695,6 +701,15 @@ describe('headless runner', () => { 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('reuses a live Agent already registered under the requested identity', async () => { const test = await bench({ afterPrompt(session, message) { appendTurn(session, 1, message, 'live answer', true) }, @@ -759,6 +774,21 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('rejects a preset selected while the runner awaits idle', async () => { + const test = await bench({ + afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) }, + }, { + sessionId: 'session-exact', + prelive: true, + onStat: (agent) => { selectPreset(agent.session, 'minimal') }, + }) + 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('rejects a preset appended after the observation snapshot was taken', async () => { const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact', diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 0984dde2d5..371d91e408 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -34,6 +34,19 @@ function assistantMessage(content: unknown[], usage?: unknown): SessionEvent { } 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, @@ -300,6 +313,85 @@ describe('--json projection', () => { .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 }).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('keeps an earlier attempt sample when the committed message reports none', () => { + 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)).toMatchObject({ usage: { inputTokens: 7, outputTokens: 3 } }) + }) + + it('ignores an attempt that 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)).toMatchObject({ usage: { inputTokens: 1, outputTokens: 1 } }) + }) + it('writes the terminal final event without bounding its answer', () => { const test = harness({ maxStringBytes: 4 }) test.projection.finish('abcdefgh') From a69933d3e5684923fee21002a4c69c41b456884a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:05:19 +0800 Subject: [PATCH 17/26] fix(headless): omit step usage when any attempt lacks a sample Address the ds-review-bot v12 critical on PR #3849: `addUsage` published the sum of the attempts that reported usage while silently dropping the ones that did not, so `step_end.usage` looked exact but under-counted billed tokens. Track completeness per step and omit usage entirely when any attempt in the step reports no sample, matching `deriveTurnTokenUsage`'s fail-closed rule. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +- ...9-headless-machine-readable-run-surface.md | 2 +- ...eadless-machine-readable-run-surface.zh.md | 2 +- packages/bundle/headless/src/json-stream.ts | 47 ++++++++++++------- .../bundle/headless/tests/json-stream.spec.ts | 14 ++++-- 5 files changed, 45 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 7143b513a2..9beeb1ad12 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: 212fd956b833cedf90804340cff22b85ce9f38aa -2026-09-09-headless-machine-readable-run-surface.zh.md: 32986ab8f950eb14c687aece7902d0e11b31e572 +2026-09-09-headless-machine-readable-run-surface.md: 4b9887c9935b34fc4019f6136bb2c8e268a447a3 +2026-09-09-headless-machine-readable-run-surface.zh.md: f9b0aa36625f1ef9615667089f11d229c151d095 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 212fd956b8..4b9887c993 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -56,7 +56,7 @@ Projection rules: - 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`, summing every attempt in the step — including a retried attempt whose only usage sample sits in its discarded `assistant/attempt` stream — so it matches the token accounting the provider billed for that step. +- `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 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 32986ab8f9..f9b0aa3662 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -56,7 +56,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - `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` 出现在 `step_end` 上,累计该步的每一次 attempt——包括仅在被丢弃的 `assistant/attempt` 流中留下用量样本的重试——与 provider 为该步计费的 token 计量一致。 +- `usage` 仅在该步每一次 attempt 都上报了样本时出现在 `step_end` 上,并累计这些样本——包括仅在被丢弃的 `assistant/attempt` 流中留下用量样本的重试——因此部分汇总不会被当作精确总量发布。 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。 ### 会话身份 diff --git a/packages/bundle/headless/src/json-stream.ts b/packages/bundle/headless/src/json-stream.ts index c2a602a2ff..f02583f2ad 100644 --- a/packages/bundle/headless/src/json-stream.ts +++ b/packages/bundle/headless/src/json-stream.ts @@ -180,6 +180,13 @@ function resultText(blocks: readonly { type: string; text?: string }[]): string /** Provider token accounting for one step, as carried by `assistant/message`. */ type StepUsage = NonNullable['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 @@ -188,15 +195,18 @@ function streamUsage(stream: SessionEvent<'assistant/message'>['data']['stream'] /** * 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 optional bucket is summed only when + * 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 total - usage accumulated so far. + * @param state - usage accumulated so far and whether it is still complete. * @param next - usage reported by the next attempt. - * @returns the accumulated usage, or `undefined` when neither side reports any. + * @returns the updated state. */ -function addUsage(total: StepUsage | undefined, next: StepUsage | undefined): StepUsage | undefined { - if (next === undefined) return total - if (total === undefined) return next +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) @@ -204,12 +214,15 @@ function addUsage(total: StepUsage | undefined, next: StepUsage | undefined): St const cacheWriteTokens = sum(total.cacheWriteTokens, next.cacheWriteTokens) const reasoningTokens = sum(total.reasoningTokens, next.reasoningTokens) return { - inputTokens: total.inputTokens + next.inputTokens, - outputTokens: total.outputTokens + next.outputTokens, - ...totalTokens === undefined ? {} : { totalTokens }, - ...cacheReadTokens === undefined ? {} : { cacheReadTokens }, - ...cacheWriteTokens === undefined ? {} : { cacheWriteTokens }, - ...reasoningTokens === undefined ? {} : { reasoningTokens }, + 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, } } @@ -235,7 +248,7 @@ export function projectJsonRun( ): JsonProjection { const maxStringBytes = options.maxStringBytes ?? MAX_STRING_BYTES let disposed = false - let stepUsage: StepUsage | undefined + let stepUsage: StepUsageState = { usage: undefined, complete: true } const write = (event: Record): void => { sink.write(`${boundJsonLine(event, maxStringBytes)}\n`) @@ -263,11 +276,13 @@ export function projectJsonRun( } return case 'step/end': { - const usage = stepUsage - stepUsage = undefined + const { usage, complete } = stepUsage + stepUsage = { usage: undefined, complete: true } write({ type: 'status', phase: 'step_end', turn: event.data.turn, step: event.data.step, - ...usage === undefined ? {} : { usage }, + // 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 } diff --git a/packages/bundle/headless/tests/json-stream.spec.ts b/packages/bundle/headless/tests/json-stream.spec.ts index 371d91e408..a9332ee894 100644 --- a/packages/bundle/headless/tests/json-stream.spec.ts +++ b/packages/bundle/headless/tests/json-stream.spec.ts @@ -373,15 +373,15 @@ describe('--json projection', () => { expect(test.parsed().at(-1)).toMatchObject({ usage: { inputTokens: 5, outputTokens: 1 } }) }) - it('keeps an earlier attempt sample when the committed message reports none', () => { + 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)).toMatchObject({ usage: { inputTokens: 7, outputTokens: 3 } }) + expect(test.parsed().at(-1)).toEqual({ type: 'status', phase: 'step_end', turn: 1, step: 1 }) }) - it('ignores an attempt that reports no usage sample', () => { + it('omits the step total when an earlier attempt reports no usage sample', () => { const test = harness() test.emitSession({ type: 'assistant/attempt', @@ -389,7 +389,13 @@ describe('--json projection', () => { } as unknown as SessionEvent) test.emitSession(assistantMessage([{ type: 'text', text: 'ok' }], { inputTokens: 1, outputTokens: 1 })) test.emitSession(stepEnd()) - expect(test.parsed().at(-1)).toMatchObject({ usage: { inputTokens: 1, outputTokens: 1 } }) + 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', () => { From ea84ad31b6474cabbc8befe774703ee0e6119789 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:43:50 +0800 Subject: [PATCH 18/26] feat(headless): require --session-id to name an existing Session `--session-id` now adopts only: an id with no stored Session fails before the task runs instead of creating an empty history the caller believes it is resuming. A first round omits the flag, and the runtime reports the minted `session-` identity in the opening `session` event, so a supervisor persists it and names it on every later wake. Updates the help text, the README pair, the Agent Note pair, the generated config catalog, the unit suite, and the product-profile e2e. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +-- ...9-headless-machine-readable-run-surface.md | 12 +++---- ...eadless-machine-readable-run-surface.zh.md | 12 +++---- .../headless/tests/headless.expected.e2e.ts | 33 +++++++++++++++++-- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 4 +-- packages/bundle/headless/README.md | 4 +-- packages/bundle/headless/README.zh.md | 4 +-- packages/bundle/headless/src/index.ts | 21 ++++++------ packages/bundle/headless/src/startup.ts | 6 ++-- .../bundle/headless/tests/headless.spec.ts | 9 +++-- 13 files changed, 73 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 9beeb1ad12..06456d12b4 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: 4b9887c9935b34fc4019f6136bb2c8e268a447a3 -2026-09-09-headless-machine-readable-run-surface.zh.md: f9b0aa36625f1ef9615667089f11d229c151d095 +2026-09-09-headless-machine-readable-run-surface.md: bf98ea7976aff9a79c159c43133b0cfd04ddb929 +2026-09-09-headless-machine-readable-run-surface.zh.md: 68fbeea65a131bde3c68a5e2cab995f3e6cdb9ae diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index 4b9887c993..bf98ea7976 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -17,7 +17,7 @@ The `dsh-headless` bundle owns an opt-in machine-readable run surface. The defau 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 ` selects the exact session identity: adopt the persisted session when it exists, otherwise create it. Without the flag the run mints `session-` as before. +- `--session-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-` 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. @@ -63,18 +63,18 @@ Projection rules: The runtime owns identity. A run without `--session-id` mints `session-` and reports it in the first event. A supervisor persists that value and passes it back on the next wake. -`--session-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. +`--session-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. All checks run when a live Agent already holds the requested id, and again after the runner's idle wait, so a live identity cannot bypass them and 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 — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing. ## Consequences -What landed: `src/startup.ts` parses `--json` and `--session-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 or creates the exact session, 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. +What landed: `src/startup.ts` parses `--json` and `--session-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. 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 live identity has no stored record, exits 1 with a diagnostic, whether the identity is live or persisted. +- 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 live identity has no stored record, exits 1 with a diagnostic, whether the identity is live or persisted. - 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. @@ -91,10 +91,10 @@ Deferred and open: **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 create-or-adopt 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. +**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. -**Create-only `--session-id`.** The second wake would fail against the existing log, which is the opposite of the continuity the flag exists for. +**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. diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index f9b0aa3662..68fbeea65a 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -17,7 +17,7 @@ Status: implemented 三项新增扩展 [Apps own their command lines](../../archived/architecture/2026-08-06-app-owned-command-line.md) 确立的 app 自有命令行: - `--json` 把 stdout 负载换成逐行 JSON 运行事件。推理变成一条事件而不再写 stderr,因此该模式下 stderr 只承载 `dsh:` 诊断。 -- `--session-id ` 选定精确的会话身份:已存在持久化会话就采用它,否则创建。不带该 flag 时,运行仍像以前一样生成 `session-`。 +- `--session-id ` 选定精确的会话身份:采用具有该 id 的持久化会话,不存在时就失败。不带该 flag 时,运行仍像以前一样生成 `session-`。 - 没有位置参数、或者位置参数为 `-` 时,任务文本改从 stdin 读取。 每次运行的 `--model` 覆盖被明确排除在范围之外;组合默认模型仍然权威。 @@ -63,18 +63,18 @@ dsh --profile headless [--json] [--session-id ] [... | -] 身份由运行时拥有。不带 `--session-id` 的运行生成 `session-`,并在第一条事件里报告它。监督进程保存该值,并在下一次唤醒时传回。 -`--session-id ` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。 +`--session-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 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,并在 runner 等待 idle 后再次执行,因此存活身份无法绕过它们,在该窗口内选中的 preset 也仍会被拒绝。纯空白的 `sessionId` 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 id,因为后续进程仍需找到它;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。 ## 后果 -实际落地:`src/startup.ts` 解析 `--json` 与 `--session-id `,把缺失或为 `-` 的任务视为"从 stdin 读取",并且只在 stdin 是终端时抛出用法错误。`src/index.ts` 解析任务、采用或创建精确会话,并接上 stderr 推理投影或新的 `src/json-stream.ts` 投影。`cordis.patch.yml` 转发这两个新设置。`package.json` 发布两个入口共同引用的共享 chunk `lib/json-stream-*.js`,因此安装后的 tarball 可以加载。 +实际落地:`src/startup.ts` 解析 `--json` 与 `--session-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` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或存活身份没有持久化记录的运行都以诊断退出 1,无论身份是存活还是持久化的。 +- 两次连续的相同 `--session-id` 运行共享历史,而请求一个没有持久化日志的 id 会在任务运行前退出 1。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或存活身份没有持久化记录的运行都以诊断退出 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 期望测试端到端覆盖两种输出模式。 @@ -91,10 +91,10 @@ dsh --profile headless [--json] [--session-id ] [... | -] **直接倾倒原始会话事件。** 它们在增量之外重复整条已组装消息,回显 `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,相对一个真实轮次很小。 +**用长驻 SDK 进程代替每次唤醒一个进程。** SDK 已经有结构化事件和显式的会话身份语义,但它会替换掉监督进程所依赖的"一次唤醒一个进程"模型。实测 headless profile 的冷启动约为热态 0.45 s、冷态 1.2 s,相对一个真实轮次很小。 **让监督进程生成会话 id。** 身份属于拥有日志的运行时。监督进程记录第一条事件报告的值即可。 -**只创建语义的 `--session-id`。** 第二次唤醒会撞上已存在的日志,与该 flag 存在的目的正好相反。 +**采用或创建语义的 `--session-id`。** 在日志不存在时创建所请求的 id,会让写错或过期的 id 静默开出一段监督进程自以为在续接的空历史;首轮本就不传该 flag 并从 `session` 事件读到生成的 id,因此没有任何调用方需要用 `--session-id` 来创建。 **任务只走 argv。** 长提示词会超出 `ARG_MAX`,并且把提示词暴露在进程列表里。 diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 48ce70655d..a0a2d90603 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -251,7 +251,7 @@ 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 with an exact session identity', async () => { + 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', @@ -260,7 +260,7 @@ describe('headless stream-json snapshots', () => { configPath: headlessOverlayPath, binArgs: [ '--profile', 'headless', '--patch', headlessOverlayPath, - '--json', '--session-id', 'headless-json-session', task, + '--json', task, ], tsconfigPath, env: { @@ -271,7 +271,8 @@ describe('headless stream-json snapshots', () => { }) const events = result.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject) - expect(events[0]).toMatchObject({ type: 'session', sessionId: 'headless-json-session' }) + 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') @@ -281,6 +282,32 @@ describe('headless stream-json snapshots', () => { 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('prints a terminal model failure through the product headless profile command', async () => { const result = await runLoaderSmoke({ label: 'product headless profile model failure snapshot', diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 186ba9c8dc..f707bdff62 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 11ad98221e257bf7e9bc404347f9f7462603c2ea -config-catalog.zh.md: 622213aaccd503f634e278a875031553f99ec36b +config-catalog.md: 673ed9757f5cc07955506cfaf8854b8d4d769753 +config-catalog.zh.md: c34568a3cc80394dbac2f8b007eb4c707d76b23c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 11ad98221e..673ed9757f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -821,7 +821,7 @@ Requires: `agentDefaultModel` · `agents` · `sessions` export interface Config { /** The prompt text for the single run; absent when the task arrives on stdin. */ task?: string - /** Exact Session identity to adopt or create; absent for a fresh random identity. */ + /** 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 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 622213aacc..c34568a3cc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -823,7 +823,7 @@ export interface Config { export interface Config { /** The prompt text for the single run; absent when the task arrives on stdin. */ task?: string - /** Exact Session identity to adopt or create; absent for a fresh random identity. */ + /** 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 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 3f920c0189..cceb12bb86 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 8bdad30de4de3e1e2c4921c2f879454c3440822f -README.zh.md: c90ed1c44d68b6bbb15f69695058922a3dc05db1 +README.md: bd218759ec9b9e8315e61f25c1310fb320aa2a89 +README.zh.md: 395dd8fdba0e8a44ec2b0544e00adb8a1f71b489 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 8bdad30de4..bd218759ec 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -44,14 +44,14 @@ The task and run options are supplied through three settings: | Field | Default | Meaning | |---|---|---| | `task` | stdin | The task text; stdin supplies it when omitted or `-` | -| `sessionId` | `session-` | Exact Session identity to adopt or create | +| `sessionId` | `session-` | 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-` identity. Pass `--session-id ` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require 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; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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. +Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-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; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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 diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index c90ed1c44d..395dd8fdba 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -44,14 +44,14 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 | 字段 | 默认值 | 含义 | |---|---|---| | `task` | stdin | 任务文本;省略或传 `-` 时由 stdin 提供 | -| `sessionId` | `session-` | 要沿用或创建的精确 Session 标识 | +| `sessionId` | `session-` | 要沿用的精确 Session 标识;未知 id 会失败 | | `json` | `false` | 把本次运行投影为 stdout 上的按行 JSON 事件 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)是所有受支持字段及其 JSDoc 的完整真源。 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识。传入 `--session-id ` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 ### 机器可读输出 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 11f44c846e..5fbcc30714 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -40,7 +40,7 @@ export const inject = ['agentDefaultModel', 'agents', 'sessions'] export interface Config { /** The prompt text for the single run; absent when the task arrives on stdin. */ task?: string - /** Exact Session identity to adopt or create; absent for a fresh random identity. */ + /** 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 @@ -241,11 +241,12 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable } /** - * 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. + * Resolve the Agent for one run: reuse a live identity or adopt the persisted + * Session with the requested id. The identity must already exist; 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 or create. + * @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 live, resumed, or freshly created Agent. @@ -295,14 +296,12 @@ async function resolveAgent( 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`) } - const { agent } = await agents.create({ - sessionId, - meta: { cwd: process.cwd() }, - agentOptions, - setup, - }) - return agent } /** Report an unexpected direct-driver failure and request a failing exit. */ diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index a0f6bb53cb..6bcd04c675 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -24,7 +24,7 @@ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' export interface HeadlessStartupValues { /** The task text this invocation asked for; absent when the runner reads stdin. */ task: string | undefined - /** Exact Session identity to adopt or create; absent for a fresh random identity. */ + /** 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 @@ -49,14 +49,14 @@ function headlessCommand(): Command { .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 ', 'adopt the persisted Session with this id, or create it when absent') + .option('--session-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 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" adopt a Session + dsh --profile headless --session-id session-… "continue" resume an existing Session `) } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 67e0576967..cbd431e06a 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -505,7 +505,7 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) - it('creates the exact requested Session when the query reports it missing', async () => { + it('rejects --session-id when the query reports the id missing', async () => { const seen: string[] = [] const test = await bench({ afterPrompt(session, message) { @@ -516,8 +516,11 @@ describe('headless runner', () => { sessionId: 'session-exact', observe: () => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')), }) - expect(await test.run()).toMatchObject({ code: 0, out: 'created\n', err: '' }) - expect(seen).toEqual(['session-exact']) + 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() }) From 127d2c8eb5ab8e64e3b220062dbe5d735d78ae42 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:19:17 +0800 Subject: [PATCH 19/26] fix(headless): align the adopt-only docs and prove resume end to end Address the ds-review-bot findings on 9aea4b0804: - Drop the stale "freshly created" wording from the resolveAgent `@returns` and rewrite its leading comment for the two remaining identity paths. - Rewrite the README run-flow paragraph (EN and ZH), the intro sentence, and the cordis.patch.yml header comment for adopt-only. - Add a real-composition two-wake e2e: capture the generated id from the first `--json` wake, resume it by `--session-id`, and assert one persisted log holds both tasks. `runLoaderSmoke` gains an optional caller-owned `cwd` so both wakes share one world; the harness leaves that directory in place. --- .../headless/tests/headless.expected.e2e.ts | 56 ++++++++++++++++++- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 4 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/headless/cordis.patch.yml | 5 +- packages/bundle/headless/src/index.ts | 10 ++-- .../loader-smoke/README.i18n.yaml | 4 +- packages/test-support/loader-smoke/README.md | 4 +- .../test-support/loader-smoke/README.zh.md | 4 +- .../test-support/loader-smoke/src/index.ts | 13 +++-- .../loader-smoke/tests/loader-smoke.spec.ts | 23 +++++++- 11 files changed, 106 insertions(+), 25 deletions(-) diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index a0a2d90603..bd3ad851f3 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -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 { @@ -308,6 +309,59 @@ describe('headless stream-json snapshots', () => { 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', + tempDirPrefix: 'headless-session-resume-', + 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', + tempDirPrefix: 'headless-session-resume-', + 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' }) + } 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', diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index cceb12bb86..18780370c1 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: bd218759ec9b9e8315e61f25c1310fb320aa2a89 -README.zh.md: 395dd8fdba0e8a44ec2b0544e00adb8a1f71b489 +README.md: 390782d702fa1fca26459cc6b2910506bb9d340f +README.zh.md: ab1bb98b69c5f951618de809e7bb3f15491ff311 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index bd218759ec..390782d702 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -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 suits scripts, CI, and one-off jobs: it opens no ports and leaves nothing behind. It also offers a JSON event stream (`--json`) and a caller-chosen identity (`--session-id`). Exit code 0 means the task completed; 1 means it aborted or errored. The 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 behind. It also offers a JSON event stream (`--json`) and `--session-id` to resume a named 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 @@ -77,7 +77,7 @@ The runner is a direct driver over the core API carrier: it creates one fresh Ag ### 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, resolves the task from config or stdin, then creates the exact Agent identity: a fresh `session-` by default, or the id `--session-id` names, which it adopts through [`sessionQuery`](../../session-query/session-query/README.md) when a persisted log exists and creates otherwise. 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. +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-` 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 diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 395dd8fdba..ab1bb98b69 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-headless` 从命令行运行一个 dsh 任务并打印最终答案,然后退出——没有 GUI、没有服务器、没有浏览器。输入 `dsh --profile headless "run the tests"`,agent(智能体)会以与所有其他表层相同的模型、工具与安全默认值完成该任务。它非常适合脚本、CI 与一次性任务:进程不打开任何端口,也不会留下任何后台运行的东西。监督进程还可以通过按行 JSON 事件流(`--json`)驱动它,并用调用方指定的标识(`--session-id`)固定这段对话。退出码告诉你结果——任务完成时为 0,中止或出错时为 1。主要边界:每次调用只运行一个任务,没有交互式后续。 +`dsh-headless` 从命令行运行一个 dsh 任务并打印最终答案,然后退出——没有 GUI、没有服务器、没有浏览器。输入 `dsh --profile headless "run the tests"`,agent(智能体)会以与所有其他表层相同的模型、工具与安全默认值完成该任务。它非常适合脚本、CI 与一次性任务:进程不打开任何端口,也不会留下任何后台运行的东西。监督进程还可以通过按行 JSON 事件流(`--json`)驱动它,并用该事件流报告的标识(`--session-id`)在同一段对话上继续唤醒。退出码告诉你结果——任务完成时为 0,中止或出错时为 1。主要边界:每次调用只运行一个任务,没有交互式后续。 ## 目录 @@ -77,7 +77,7 @@ runner 是核心 API 载体之上的直接驱动器:它通过注册表创建 ### 运行流程 -runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组合的工具与适配器不会半挂载,读取共享的 [`agentDefaultModel`](../../core/agent-default-model/README.zh.md) 选择,从配置或 stdin 解析任务,然后确定精确的 Agent 标识:默认是全新的 `session-`,或 `--session-id` 指定的 id——存在持久化日志时通过 [`sessionQuery`](../../session-query/session-query/README.zh.md) 沿用,否则创建。它把任务作为普通用户消息提交。不带 `--json` 时,它把该 Agent 的非空推理增量流式写入 stderr;带 `--json` 时改为投影本次运行。它等待完全停稳,然后对会话执行 flush,并把所属区间(从 `firstSeq` 起)折叠为最后一条非空 `assistant/message` 文本与最终 `turn/end` 原因。最后,它把最终文本写入 stdout(或 `final` 事件)并请求退出。 +runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组合的工具与适配器不会半挂载,读取共享的 [`agentDefaultModel`](../../core/agent-default-model/README.zh.md) 选择,从配置或 stdin 解析任务,然后确定 Agent 标识:默认是全新的 `session-`,或是 `--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 内容 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index c7b514729d..1c80f91b07 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -2,8 +2,9 @@ # It mounts no Host, HTTP server, Web runtime, or browser plugin. An ordinary # provider plugin injects `cmdlineArgs`, parses the task positional # (`dsh --profile headless ""`), the `--session-id` and `--json` options, -# and this app's --help, then the direct driver creates or adopts an Agent -# through the core registry and prints its durable result. +# 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: diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 5fbcc30714..4de6c26a5c 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -249,7 +249,7 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable * @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 live, resumed, or freshly created Agent. + * @returns the live or resumed Agent. */ async function resolveAgent( ctx: Context, @@ -258,10 +258,10 @@ async function resolveAgent( agentOptions: { provider: string; model: string }, setup: (agentCtx: Context) => void, ): Promise { - // Adopting a live identity and creating a missing one both promise 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 either path. + // Reusing a live identity and resuming a stored one both promise 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 either path. const persistence = ctx.get('sessionPersistence') if (persistence === undefined) { throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process') diff --git a/packages/test-support/loader-smoke/README.i18n.yaml b/packages/test-support/loader-smoke/README.i18n.yaml index e6d254ca1e..c8e0cfb370 100644 --- a/packages/test-support/loader-smoke/README.i18n.yaml +++ b/packages/test-support/loader-smoke/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/test-support/loader-smoke/README.md -README.md: dd618358aeb6dca2fbaf3a4945d09aa529b28f48 -README.zh.md: bc86a92ed867fbd33f1e1c3aebb6b4ab5aefc7ce +README.md: 4365cc16beadc517390dd4ce8616cdfb6ec9e7be +README.zh.md: 3d9659acf7b845bab11be3a5f6c0ff37e15b8988 diff --git a/packages/test-support/loader-smoke/README.md b/packages/test-support/loader-smoke/README.md index dd618358ae..4365cc16be 100644 --- a/packages/test-support/loader-smoke/README.md +++ b/packages/test-support/loader-smoke/README.md @@ -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 diff --git a/packages/test-support/loader-smoke/README.zh.md b/packages/test-support/loader-smoke/README.zh.md index bc86a92ed8..3d9659acf7 100644 --- a/packages/test-support/loader-smoke/README.zh.md +++ b/packages/test-support/loader-smoke/README.zh.md @@ -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 完全停稳;随后汇总每步用量,并在返回前刷写会话。 ### 源码地图 diff --git a/packages/test-support/loader-smoke/src/index.ts b/packages/test-support/loader-smoke/src/index.ts index 100bdca67a..5a96e4dae1 100644 --- a/packages/test-support/loader-smoke/src/index.ts +++ b/packages/test-support/loader-smoke/src/index.ts @@ -138,6 +138,8 @@ export interface LoaderSmokeOptions { readonly tempDirPrefix: string /** Existing parent for the generated cwd; defaults to the platform temporary directory. */ readonly tempDirParent?: string + /** Existing process cwd to reuse instead of a fresh temporary directory; the caller owns its cleanup. */ + readonly cwd?: string /** Absolute app-bin source path (`/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. */ @@ -177,13 +179,16 @@ export interface LoaderSmokeResult { /** * 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 { - const cwd = await mkdtemp(join(options.tempDirParent ?? tmpdir(), options.tempDirPrefix)) + const cwd = options.cwd ?? await mkdtemp(join(options.tempDirParent ?? tmpdir(), options.tempDirPrefix)) + const ownsCwd = options.cwd === undefined const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS try { await options.prepare?.(cwd) @@ -222,6 +227,6 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { 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', + tempDirPrefix: 'loader-smoke-shared-unused-', + 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 = '' From 9bcb1e214c176a5e3e6fa8132cf314ce7ccf47d8 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:00:53 +0800 Subject: [PATCH 20/26] fix(headless): refuse a live Agent identity and finish the adopt-only docs Address the ds-review-bot findings on 472554a29e: - resolveAgent refuses an Agent already live under the requested id: its owner may still drive it, and `whenIdle` is not a single-message signal, so the one-shot runner cannot own an exclusive run interval over it. The adoptability rules still run first so the refusal stays specific. - Refresh the README intro, identity section, and limitations, and the Agent Note live-identity rule and change scope, for adopt-only. - Clarify the loader-smoke `cwd` JSDoc (the prefix/parent inputs are ignored) and pin the resumed wake's final text in the two-wake e2e. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +- ...9-headless-machine-readable-run-surface.md | 6 +-- ...eadless-machine-readable-run-surface.zh.md | 6 +-- .../headless/tests/headless.expected.e2e.ts | 5 +- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 6 +-- packages/bundle/headless/README.zh.md | 6 +-- packages/bundle/headless/src/index.ts | 37 ++++++------- .../bundle/headless/tests/headless.spec.ts | 53 +++---------------- .../test-support/loader-smoke/src/index.ts | 6 ++- 10 files changed, 50 insertions(+), 83 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 06456d12b4..e845acce34 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: bf98ea7976aff9a79c159c43133b0cfd04ddb929 -2026-09-09-headless-machine-readable-run-surface.zh.md: 68fbeea65a131bde3c68a5e2cab995f3e6cdb9ae +2026-09-09-headless-machine-readable-run-surface.md: c436e57c4c3461a1d3700ce873041e3b8ab9a603 +2026-09-09-headless-machine-readable-run-surface.zh.md: b55ec5f7df5c86720d9aa611594bbadd335a1b31 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index bf98ea7976..c436e57c4c 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -22,7 +22,7 @@ Three additions extend the app-owned command line that [Apps own their command l A per-run `--model` override is deliberately out of scope; the composition default stays authoritative. -The 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. No core session, persistence, session-controller, base composition, or launcher file changes. +The 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. The product-profile expectation test in `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` covers both output modes end to end, which adds one optional caller-owned `cwd` to the `packages/test-support/loader-smoke` harness so two wakes can share a world. No core session, persistence, session-controller, base composition, or launcher file changes. ### Command-line contract @@ -65,7 +65,7 @@ The runtime owns identity. A run without `--session-id` mints `session-` a `--session-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. All checks run when a live Agent already holds the requested id, and again after the runner's idle wait, so a live identity cannot bypass them and 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 — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing. +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 @@ -74,7 +74,7 @@ What landed: `src/startup.ts` parses `--json` and `--session-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. - 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 live identity has no stored record, exits 1 with a diagnostic, whether the identity is live or persisted. +- 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. diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 68fbeea65a..b55ec5f7df 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -22,7 +22,7 @@ Status: implemented 每次运行的 `--model` 覆盖被明确排除在范围之外;组合默认模型仍然权威。 -改动范围限于 `packages/bundle/headless`:`src/startup.ts`、`src/index.ts`、新增的 `src/json-stream.ts`、包清单与 `tsconfig.json`,以及测试。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。 +改动范围限于 `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`,让两次唤醒共享同一个世界。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。 ### 命令行契约 @@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] `--session-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 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,并在 runner 等待 idle 后再次执行,因此存活身份无法绕过它们,在该窗口内选中的 preset 也仍会被拒绝。纯空白的 `sessionId` 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 id,因为后续进程仍需找到它;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。 +采用时会比较持久化会话记录的 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` 服务,同样显式失败。 ## 后果 @@ -74,7 +74,7 @@ dsh --profile headless [--json] [--session-id ] [... | -] - 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。 - `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。 - 发生重试的步骤只为最终提交的 attempt 发布 `text` 与 `thinking`,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。 -- 两次连续的相同 `--session-id` 运行共享历史,而请求一个没有持久化日志的 id 会在任务运行前退出 1。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或存活身份没有持久化记录的运行都以诊断退出 1,无论身份是存活还是持久化的。 +- 两次连续的相同 `--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 期望测试端到端覆盖两种输出模式。 diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index bd3ad851f3..13e1281d9c 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -356,7 +356,10 @@ describe('headless stream-json snapshots', () => { }) 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' }) + 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 }) } diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 18780370c1..4e899aadcc 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 390782d702fa1fca26459cc6b2910506bb9d340f -README.zh.md: ab1bb98b69c5f951618de809e7bb3f15491ff311 +README.md: 76712b905ac515d8fcf3c908d62795c7cbd0d782 +README.zh.md: 550036e7fa0650781628e709b3429cc005b7ac66 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 390782d702..76712b905a 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -51,7 +51,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Choosing the session identity -Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-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; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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. +Every invocation defaults to a fresh `session-` identity, which `--json` reports in its opening `session` event. Pass `--session-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 @@ -73,7 +73,7 @@ Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick
Implementation internals — click to expand -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-` by default, or the persisted Session `--session-id` names — and folds the owned durable event interval into one process-level outcome. ### Run flow @@ -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 or whose preset record is malformed, and requires the composed Session query and persistence services plus a stored record for a live identity. +- **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. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index ab1bb98b69..550036e7fa 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -51,7 +51,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 ### 选择 Session 标识 -每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 +每次调用默认使用全新的 `session-` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id ` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。 ### 机器可读输出 @@ -73,7 +73,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
实现细节——点击展开 -runner 是核心 API 载体之上的直接驱动器:它通过注册表创建一个全新的 Agent,并把所属的持久化事件区间折叠成一个进程级结果。 +runner 是核心 API 载体之上的直接驱动器:它确定 Agent 标识——默认是全新的 `session-`,或 `--session-id` 指名的持久化 Session——并把所属的持久化事件区间折叠成一个进程级结果。 ### 运行流程 @@ -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、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务,且存活身份必须已有持久化记录。 +- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或运行在本 profile 不组合的 agent preset 下的 Session、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务;本进程中已存活的身份同样会被拒绝,因为 runner 无法对它取得独占的运行区间。 - **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 4de6c26a5c..df64c70a8e 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -241,15 +241,16 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable } /** - * Resolve the Agent for one run: reuse a live identity or adopt the persisted - * Session with the requested id. The identity must already exist; a first round - * omits the option instead, so a typo cannot pass as a brand-new conversation. + * 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 live or resumed Agent. + * @returns the resumed Agent. */ async function resolveAgent( ctx: Context, @@ -258,32 +259,28 @@ async function resolveAgent( agentOptions: { provider: string; model: string }, setup: (agentCtx: Context) => void, ): Promise { - // Reusing a live identity and resuming a stored one both promise 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 either path. - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) { + // 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 even when this - // process already has the identity live. + // 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 identity skips adoption, not the rules that make adoption safe. + // 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) - // A stored record rules out an Agent registered only in memory, whose - // `session/flush` would store nothing; write-handle ownership itself is - // not queryable through the persistence contract. - if (await persistence.stat(sessionId) === undefined) { - throw new Error(`live session "${sessionId}" has no persisted record, so the one-shot runner cannot promise it survives this process`) - } - return live + 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) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index cbd431e06a..abaf0a1108 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -48,14 +48,10 @@ interface BenchOptions { omitSessionQuery?: boolean /** Leave the persistence service unmounted to exercise the fail-loud path. */ omitPersistence?: boolean - /** Mount the service but make it report no stored record for any id. */ - unbackedPersistence?: 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 live path reads persistence, e.g. to mutate the live log. */ - onStat?: (agent: Agent) => void } const frameStates = new WeakMap; revision: number; index: number }>() @@ -182,14 +178,8 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ const observe = options.observe ?? (() => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND'))) ctx.provide('sessionQuery', { observeSession: () => observe() } as never) } - let preliveAgent: Agent | undefined if (options.omitPersistence !== true) { - ctx.provide('sessionPersistence', { - stat: () => { - if (options.onStat !== undefined && preliveAgent !== undefined) options.onStat(preliveAgent) - return Promise.resolve(options.unbackedPersistence === true ? undefined : { header: {} }) - }, - } as never) + ctx.provide('sessionPersistence', {} as never) } return { ctx, @@ -203,10 +193,10 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) }) }) if (options.prelive === true || options.preliveMeta !== undefined) { - preliveAgent = (await ctx.agents.create({ + await ctx.agents.create({ sessionId: brandString(options.sessionId ?? 'session-exact'), meta: { cwd: process.cwd(), ...options.preliveMeta }, - })).agent + }) } apply(ctx, { ...options.useStdin === true ? {} : { task: options.task ?? 'do the thing' }, @@ -554,21 +544,6 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) - it('rejects a live Session the persistence service does not know', async () => { - const test = await bench({ - afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) }, - }, { - sessionId: 'session-exact', - prelive: true, - unbackedPersistence: true, - }) - const result = await test.run() - expect(result.code).toBe(1) - expect(result.err).toContain('has no persisted record') - 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) }, @@ -713,14 +688,17 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) - it('reuses a live Agent already registered under the requested identity', async () => { + 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, }) - expect(await test.run()).toMatchObject({ code: 0, out: 'live answer\n', err: '' }) + 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() }) @@ -777,21 +755,6 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) - it('rejects a preset selected while the runner awaits idle', async () => { - const test = await bench({ - afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) }, - }, { - sessionId: 'session-exact', - prelive: true, - onStat: (agent) => { selectPreset(agent.session, 'minimal') }, - }) - 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('rejects a preset appended after the observation snapshot was taken', async () => { const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact', diff --git a/packages/test-support/loader-smoke/src/index.ts b/packages/test-support/loader-smoke/src/index.ts index 5a96e4dae1..bdc1f96ba9 100644 --- a/packages/test-support/loader-smoke/src/index.ts +++ b/packages/test-support/loader-smoke/src/index.ts @@ -138,7 +138,11 @@ export interface LoaderSmokeOptions { readonly tempDirPrefix: string /** Existing parent for the generated cwd; defaults to the platform temporary directory. */ readonly tempDirParent?: string - /** Existing process cwd to reuse instead of a fresh temporary directory; the caller owns its cleanup. */ + /** + * Existing directory to use as the process cwd instead of a fresh temporary + * one; the caller owns its cleanup, and `tempDirPrefix`/`tempDirParent` are + * ignored. + */ readonly cwd?: string /** Absolute app-bin source path (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly binScript: string From 9098a58f94f15e1c9b9f6ac08c32d408a1bdbb06 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:03:46 +0800 Subject: [PATCH 21/26] refactor(loader-smoke): model the smoke cwd as a discriminated input Address the review suggestion that a caller-provided `cwd` left `tempDirPrefix`/`tempDirParent` as dead but still-required inputs. The options are now a union of "expand a prefix into a fresh temporary cwd" and "reuse this existing directory", so the type system rejects passing both; the two callers that reuse a directory drop their unused prefix. --- .../headless/tests/headless.expected.e2e.ts | 2 - .../test-support/loader-smoke/src/index.ts | 50 +++++++++++++------ .../loader-smoke/tests/loader-smoke.spec.ts | 1 - 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 13e1281d9c..f8d342b462 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -321,7 +321,6 @@ describe('headless stream-json snapshots', () => { try { const first = await runLoaderSmoke({ label: 'product headless profile resume first wake', - tempDirPrefix: 'headless-session-resume-', cwd, binScript: dshBinScript, configPath: headlessOverlayPath, @@ -336,7 +335,6 @@ describe('headless stream-json snapshots', () => { const second = await runLoaderSmoke({ label: 'product headless profile resume second wake', - tempDirPrefix: 'headless-session-resume-', cwd, binScript: dshBinScript, configPath: headlessOverlayPath, diff --git a/packages/test-support/loader-smoke/src/index.ts b/packages/test-support/loader-smoke/src/index.ts index bdc1f96ba9..457434b844 100644 --- a/packages/test-support/loader-smoke/src/index.ts +++ b/packages/test-support/loader-smoke/src/index.ts @@ -130,20 +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 - /** - * Existing directory to use as the process cwd instead of a fresh temporary - * one; the caller owns its cleanup, and `tempDirPrefix`/`tempDirParent` are - * ignored. - */ - readonly cwd?: string /** Absolute app-bin source path (`/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. */ @@ -173,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. */ @@ -181,6 +192,13 @@ 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 on every outcome and removes @@ -191,8 +209,10 @@ export interface LoaderSmokeResult { * @returns captured stdout and stderr after a zero exit. */ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { - const cwd = options.cwd ?? await mkdtemp(join(options.tempDirParent ?? tmpdir(), options.tempDirPrefix)) - const ownsCwd = options.cwd === undefined + 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) @@ -231,6 +251,6 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { try { const result = await runLoaderSmoke({ label: 'shared cwd fixture', - tempDirPrefix: 'loader-smoke-shared-unused-', cwd, binScript: fixture('success'), configPath, From 31be030ccca14355eb9e8dee3653818f44f739ae Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:25:19 +0800 Subject: [PATCH 22/26] fix(headless): keep --json stderr free of commander's error print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the ds-review-bot findings on 8724223621: - The JSON error override throws commander's control-flow error instead of delegating to `program.error`, whose own printer wrote an unprefixed `error: …` line to stderr and broke the documented `--json` contract. - Cover the post-idle adoptability re-check with a focused case: an overlay that appends a preset while the runner awaits idle is still rejected, and the stale "live Session" comment now names that residual writer. - Scope the Agent Note change sentence to the product change so it no longer claims the whole patch is confined to one package. --- ...ess-machine-readable-run-surface.i18n.yaml | 4 +-- ...9-headless-machine-readable-run-surface.md | 2 +- ...eadless-machine-readable-run-surface.zh.md | 2 +- packages/bundle/headless/src/index.ts | 5 ++-- packages/bundle/headless/src/startup.ts | 12 +++++---- .../bundle/headless/tests/headless.spec.ts | 25 ++++++++++++++++++- .../bundle/headless/tests/startup.spec.ts | 17 +++++++++++-- 7 files changed, 53 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index e845acce34..163ff9b1d3 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: c436e57c4c3461a1d3700ce873041e3b8ab9a603 -2026-09-09-headless-machine-readable-run-surface.zh.md: b55ec5f7df5c86720d9aa611594bbadd335a1b31 +2026-09-09-headless-machine-readable-run-surface.md: e49efb3761a8c43d06fff3f3d10de68eef16dcc3 +2026-09-09-headless-machine-readable-run-surface.zh.md: 465d07b966576b08ed0085da752ec8da647eec58 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index c436e57c4c..e49efb3761 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -22,7 +22,7 @@ Three additions extend the app-owned command line that [Apps own their command l A per-run `--model` override is deliberately out of scope; the composition default stays authoritative. -The 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. The product-profile expectation test in `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` covers both output modes end to end, which adds one optional caller-owned `cwd` to the `packages/test-support/loader-smoke` harness so two wakes can share a world. No core session, persistence, session-controller, base composition, or launcher file changes. +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, while `scripts/check-workspace-constraints.ts`, `pnpm-lock.yaml`, and the package manifest publish the shared `lib/json-stream-*.js` chunk both entries import. No core session, persistence, session-controller, base composition, or launcher file changes. ### Command-line contract diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index b55ec5f7df..465d07b966 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -22,7 +22,7 @@ Status: implemented 每次运行的 `--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`,让两次唤醒共享同一个世界。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。 +产品改动限于 `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`、`pnpm-lock.yaml` 与包清单负责发布两个入口共同引用的共享 chunk `lib/json-stream-*.js`。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。 ### 命令行契约 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index df64c70a8e..3aa7232f9d 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -359,8 +359,9 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise : await resolveAgent(ctx, agents, sessionId, agentOptions, setup) await agent.whenIdle() if (config.sessionId !== undefined) { - // A live Session can select an agent preset while the runner awaits idle; - // re-read its log so the rejection cannot be outrun by that timing. + // 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 diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 6bcd04c675..46525ddbfb 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -6,7 +6,7 @@ * @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' @@ -89,13 +89,15 @@ export function apply(ctx: Context): void { // 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() ?? [])) { - const originalError = program.error.bind(program) - program.error = (message: string, errorOptions?: Parameters[1]): never => { + program.error = (message: string, errorOptions?: Parameters[1]): never => { // The event message matches the runner's runtime errors, which carry no - // commander `error: ` prefix; the stderr line keeps commander's text. + // commander `error: ` prefix. const payload = boundJsonLine({ type: 'error', message: message.replace(/^error: /, '') }) internals.stdout.write(`${payload}\n`) - return originalError(message, errorOptions) + // 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(() => { diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index abaf0a1108..aadcad5d0d 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -52,6 +52,8 @@ interface BenchOptions { 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; revision: number; index: number }>() @@ -147,7 +149,10 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ }, steer: () => {}, inject: () => {}, - whenIdle: () => idle, + whenIdle: () => { + options.onWhenIdle?.(agent) + return idle + }, } await createOptions.setup?.(ownerCtx, agent) ctx.agents.register(agent) @@ -772,6 +777,24 @@ describe('headless runner', () => { 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('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({ diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index e8697746b4..6d88fbe85c 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -24,6 +24,7 @@ import { interface Observed { exits: number[] out: string + err: string runnerConfig?: unknown } @@ -56,7 +57,7 @@ async function bootStartup( ): 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. @@ -79,8 +80,17 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + // 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 = observing + cmdlineInternals.stderr = observingErr startupInternals.stdinIsTty = () => options.stdinIsTty === true startupInternals.stdout = observing const globals = globalThis as unknown as { @@ -163,6 +173,7 @@ describe('headless command-line provider', () => { 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]) }) @@ -171,6 +182,7 @@ describe('headless command-line provider', () => { 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]) }) @@ -179,6 +191,7 @@ describe('headless command-line provider', () => { 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]) }) From 9b330b653f1f5f2552ab21698c4509c06e08a054 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:52:43 +0800 Subject: [PATCH 23/26] docs(headless): correct the adopt-only wording and lockfile attribution Address the ds-review-bot suggestions on 4099b84ee8: - The post-resume comment scopes the write lease to other processes, so it no longer contradicts the post-idle re-check that defends against a same-process overlay writer. - The Agent Note attributes `pnpm-lock.yaml` to the new `@deepseek-ai/dsh-session-query` workspace link instead of the shared chunk, which `scripts/check-workspace-constraints.ts` and the manifest publish. - The package summary keeps "leaves nothing running behind", so it does not imply the runner leaves no persisted Session behind. --- ...2026-09-09-headless-machine-readable-run-surface.i18n.yaml | 4 ++-- .../2026-09-09-headless-machine-readable-run-surface.md | 2 +- .../2026-09-09-headless-machine-readable-run-surface.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 2 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/src/index.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index 163ff9b1d3..d704c77ad2 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: e49efb3761a8c43d06fff3f3d10de68eef16dcc3 -2026-09-09-headless-machine-readable-run-surface.zh.md: 465d07b966576b08ed0085da752ec8da647eec58 +2026-09-09-headless-machine-readable-run-surface.md: 317f213095f86e94104c013139390cdf988f95fe +2026-09-09-headless-machine-readable-run-surface.zh.md: 0114f87aef4f25db0887663464973bb2f762b2d5 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index e49efb3761..317f213095 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md @@ -22,7 +22,7 @@ Three additions extend the app-owned command line that [Apps own their command l 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, while `scripts/check-workspace-constraints.ts`, `pnpm-lock.yaml`, and the package manifest publish the shared `lib/json-stream-*.js` chunk both entries import. No core session, persistence, session-controller, base composition, or launcher file changes. +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 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index 465d07b966..0114f87aef 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md @@ -22,7 +22,7 @@ Status: implemented 每次运行的 `--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`、`pnpm-lock.yaml` 与包清单负责发布两个入口共同引用的共享 chunk `lib/json-stream-*.js`。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。 +产品改动限于 `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 文件。 ### 命令行契约 diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 4e899aadcc..791c3c026c 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/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: 76712b905ac515d8fcf3c908d62795c7cbd0d782 +README.md: 859b39ed3bc14d7d9f85db179bcbe712918c4171 README.zh.md: 550036e7fa0650781628e709b3429cc005b7ac66 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 76712b905a..859b39ed3b 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -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 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 behind. It also offers a JSON event stream (`--json`) and `--session-id` to resume a named conversation. Exit code 0 means the task completed; 1 means it aborted or errored. The boundary: one task per invocation, 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 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 3aa7232f9d..ca64916f6d 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -288,7 +288,7 @@ async function resolveAgent( 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 writer can append. + // resume actually attached, now that no other process can append. assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId) return agent } catch (error: unknown) { From 6d057654051380fd6c0cf95e07beaf2844c25a40 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:29:15 +0800 Subject: [PATCH 24/26] refactor(headless): keep the startup process seam out of the public entry Address the Code Entropy finding on f67d722243: the `./startup` entry exported `internals` only so tests could substitute process facts, which added avoidable package API. Move the seam to `src/startup-internals.ts`, which no package export names, and have the provider and its tests import it from there. --- packages/bundle/headless/src/startup-internals.ts | 14 ++++++++++++++ packages/bundle/headless/src/startup.ts | 10 +--------- packages/bundle/headless/tests/startup.spec.ts | 2 +- 3 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 packages/bundle/headless/src/startup-internals.ts diff --git a/packages/bundle/headless/src/startup-internals.ts b/packages/bundle/headless/src/startup-internals.ts new file mode 100644 index 0000000000..9356ec9c47 --- /dev/null +++ b/packages/bundle/headless/src/startup-internals.ts @@ -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, +} diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 46525ddbfb..02886388d9 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -10,6 +10,7 @@ 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' @@ -30,15 +31,6 @@ export interface HeadlessStartupValues { json: boolean } -/** 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, -} - /** * 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). diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 6d88fbe85c..36965a74b7 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -16,9 +16,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { apply, HEADLESS_STARTUP_SERVICE, - internals as startupInternals, 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 { From 3be1ea10b9a49261056c23a96257aa0a04824e6f Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:10:09 +0800 Subject: [PATCH 25/26] refactor(headless): move the runner process hooks out of the package entry Address the Code Entropy follow-up on d9c4f53758: the runner entry still expanded its process-substitution object with a `readStdin` hook used only by tests. Move that seam to `src/runner-internals.ts`, mirroring `src/startup-internals.ts`, so neither entry grows package API for tests. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/bundle/headless/src/index.ts | 16 +------------- .../bundle/headless/src/runner-internals.ts | 21 +++++++++++++++++++ .../bundle/headless/tests/headless.spec.ts | 3 ++- 6 files changed, 28 insertions(+), 20 deletions(-) create mode 100644 packages/bundle/headless/src/runner-internals.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1ba7cca306..4ed7064ff2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 84b624307723822fc0494a032e4930a364a81b74 -config-catalog.zh.md: 3a0bf27b73a30793a8e1e7ab97670e9d8650e993 +config-catalog.md: 29d268efb57badd0fa0caf2e2222aad118b3978f +config-catalog.zh.md: b1be54d2dc2e7a4c0b1a9e1716b1a0928d372892 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 84b6243077..29d268efb5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -828,7 +828,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:40`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:41`](../packages/bundle/headless/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3a0bf27b73..b1be54d2dc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -830,7 +830,7 @@ export interface Config { } ``` -来源:[`packages/bundle/headless/src/index.ts:40`](../packages/bundle/headless/src/index.ts) +来源:[`packages/bundle/headless/src/index.ts:41`](../packages/bundle/headless/src/index.ts) diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index ca64916f6d..b1f003979b 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -28,6 +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 { internals } from './runner-internals.ts' import { projectJsonRun, boundJsonLine } from './json-stream.ts' /** Stable Cordis plugin name. */ @@ -66,21 +67,6 @@ interface HeadlessIo { exit(code: number): void } -/** The process streams the runner reads and writes; tests substitute captures. */ -export const internals: { - stdout: HeadlessIo['stdout'] - stderr: HeadlessIo['stderr'] - readStdin: () => Promise -} = { - stdout: process.stdout, - stderr: process.stderr, - readStdin: async () => { - const chunks: Buffer[] = [] - for await (const chunk of process.stdin as AsyncIterable) chunks.push(chunk) - return Buffer.concat(chunks).toString('utf8') - }, -} - /** Aggregate the last assistant text and turn outcome in one owned interval. */ function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome { let started = false diff --git a/packages/bundle/headless/src/runner-internals.ts b/packages/bundle/headless/src/runner-internals.ts new file mode 100644 index 0000000000..1411995cd5 --- /dev/null +++ b/packages/bundle/headless/src/runner-internals.ts @@ -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 +} = { + stdout: process.stdout, + stderr: process.stderr, + readStdin: async () => { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin as AsyncIterable) chunks.push(chunk) + return Buffer.concat(chunks).toString('utf8') + }, +} diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index aadcad5d0d..1da176410f 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -19,7 +19,8 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' 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) }) From 71b2cc80aa219f9211cd245de280e4bb37a0a47b Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:32:54 +0800 Subject: [PATCH 26/26] fix(headless): waive the deferred Session history read Master's `deprecate-synchronous-session-event-reads` marks `Session.eventAt` deprecated and rejects new production calls. `liveEvents` predates that decision and still needs the ordered log to reconstruct the preset a session runs under, so carry the repository's standard line-scoped waiver for deferred migration. --- packages/bundle/headless/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 183cc545dc..16594cd440 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -174,6 +174,7 @@ interface AdoptableHeader { function* liveEvents(session: Session): Generator { 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)}`)