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.
This commit is contained in:
lsdsjy committed 2026-09-09 17:40:47 +08:00
1 parent 3797127eb2
commit 0ca42f3b78
14 files changed
+123 -15

No files matched your search

@@ -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
@@ -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
@@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
- 文本与推理只从已提交的 `assistant/message` 投影,绝不来自实时的 attempt 增量。被重试或丢弃的 attempt 会追加 `assistant/attempt`,投影直接忽略,因此事件流永远不会承载持久化日志中不存在的内容([只在提交点发布状态](../../../../packages/AGENTS.md))。
- 每个已提交的内容块按内容顺序变成恰好一条 `text` 或 `thinking` 事件;`tool-call` 块不投影,因为 `tool/call` 事件已经拥有它。`user/message` 回显和内部会话事件(标题、模型选择、投影、检查点、目标、子 agent)都不投影。
- `tool/result` 仅在其 `surfaceOp` 为 `append` 时投影。压缩对旧结果的替换属于历史,投影它会产生没有对应 `tool_call` 的 call id。
- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,进程级 `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 <id>] [<task>... | -]
- 每次运行的 `--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` 事件是唯一的例外。
## 备选方案
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
README.md: 950c562cd61047db0b7bb2c72961e49eee0a2799
README.zh.md: 23331334a6490778f8b18da8f09fa4a4008df42e
README.md: 97ba97eec987f304ac163cbac945bf7764a28d78
README.zh.md: 526e4ebf12d3a24d9f23ad8e3511f1c5a5e0d4d2
+1 -1
View File
@@ -55,7 +55,7 @@ Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-
### Machine-readable output
`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `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
+1 -1
View File
@@ -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` 原因作为失败信号。
### 何时使用
+1 -1
View File
@@ -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",
+4
View File
@@ -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
+34 -2
View File
@@ -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<string, unknown>,
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<string, unknown>
for (const [key, value] of Object.entries(bounded)) {
if (value === null || typeof value !== 'object') scalars[key] = value
}
scalars.truncated = true
const short = JSON.stringify(scalars)
if (Buffer.byteLength(short, 'utf8') <= 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<string, unknown>): void => {
sink.write(`${JSON.stringify(boundJsonEvent(event, maxStringBytes))}\n`)
sink.write(`${boundJsonLine(event, maxStringBytes)}\n`)
}
const onSessionEvent = (session: unknown, event: SessionEvent): void => {
+18 -1
View File
@@ -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<typeof originalError>[1]): never => {
// The event message matches the runner's runtime errors, which carry no
@@ -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<SessionId>('session-exact'), { meta: { cwd: process.cwd() } })
selectPreset(session, 'minimal')
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('runs under agent preset "minimal"')
await test.ctx.fiber.dispose()
})
it('fails when a live event below the captured Session length cannot be read', async () => {
let capturedLength = 0
const test = await bench({
@@ -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')
@@ -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')
+3
View File
@@ -184,6 +184,9 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// through a hashed chunk. The committed bin.js is the link target pnpm can
// resolve at install time, before the build produces lib/bin.js.
'@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
// The headless entry and its startup row share the JSON projection code
// through a hashed tsdown chunk; both import it by relative path.
'@deepseek-ai/dsh-headless': ['lib/json-stream-*.js'],
}
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {