Browse Source

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.
lsdsjy 3 weeks ago
parent
commit
127d2c8eb5

+ 55 - 1
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',

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
-README.md: bd218759ec9b9e8315e61f25c1310fb320aa2a89
-README.zh.md: 395dd8fdba0e8a44ec2b0544e00adb8a1f71b489
+README.md: 390782d702fa1fca26459cc6b2910506bb9d340f
+README.zh.md: ab1bb98b69c5f951618de809e7bb3f15491ff311

+ 2 - 2
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-<uuid>` 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-<uuid>` by default, or the persisted Session `--session-id` names, which it adopts through [`sessionQuery`](../../session-query/session-query/README.md) and refuses when no log exists. It submits the task as an ordinary user message. Without `--json` it streams that Agent's non-empty reasoning deltas to stderr; with `--json` it projects the run instead. It waits for quiescence, then flushes the Session and folds the owned interval (`firstSeq` onward) into the last non-empty `assistant/message` text and final `turn/end` reason. It writes the final text to stdout (or the `final` event) and requests exit.
 
 ### Patch surface over base
 

+ 2 - 2
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-<uuid>`,或 `--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-<uuid>`,或是 `--session-id` 指名的持久化 Session——通过 [`sessionQuery`](../../session-query/session-query/README.zh.md) 沿用,日志不存在时拒绝。它把任务作为普通用户消息提交。不带 `--json` 时,它把该 Agent 的非空推理增量流式写入 stderr;带 `--json` 时改为投影本次运行。它等待完全停稳,然后对会话执行 flush,并把所属区间(从 `firstSeq` 起)折叠为最后一条非空 `assistant/message` 文本与最终 `turn/end` 原因。最后,它把最终文本写入 stdout(或 `final` 事件)并请求退出。
 
 ### 基于 base 的 patch 内容
 

+ 3 - 2
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 "<task>"`), 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:

+ 5 - 5
packages/bundle/headless/src/index.ts

@@ -249,7 +249,7 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>
  * @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<Agent> {
-  // 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')

+ 2 - 2
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

+ 2 - 2
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
 

+ 2 - 2
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 完全停稳;随后汇总每步用量,并在返回前刷写会话。
 
 ### 源码地图
 

+ 9 - 4
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 (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
   readonly binScript: string
   /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */
@@ -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<LoaderSmokeResult> {
-  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<Loade
     await options.inspect?.(cwd)
     return { stdout: result.stdout, stderr: result.stderr }
   } finally {
-    await rm(cwd, { recursive: true, force: true })
+    if (ownsCwd) await rm(cwd, { recursive: true, force: true })
   }
 }

+ 22 - 1
packages/test-support/loader-smoke/tests/loader-smoke.spec.ts

@@ -1,5 +1,6 @@
 import { existsSync } from 'node:fs'
-import { readFile, writeFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
@@ -43,6 +44,26 @@ describe('runLoaderSmoke', () => {
     expect(existsSync(output.cwd)).toBe(false)
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
+  it('reuses a caller-provided cwd and leaves it in place', async () => {
+    const cwd = await mkdtemp(join(tmpdir(), 'loader-smoke-shared-'))
+    try {
+      const result = await runLoaderSmoke({
+        label: 'shared cwd fixture',
+        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 = ''